mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-19 22:25:44 +02:00
Compare commits
33
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
13f2ecc84b | ||
|
|
af5dab5b77 | ||
|
|
312c6d0ac1 | ||
|
|
f1dc4577e2 | ||
|
|
1fcb768182 | ||
|
|
68fa011fc9 | ||
|
|
ac3f5b007b | ||
|
|
a9b0a05fb5 | ||
|
|
b7fd3cf9c4 | ||
|
|
64bd4d1f13 | ||
|
|
f25a0f4f4c | ||
|
|
ea4aa79a60 | ||
|
|
c7792608e3 | ||
|
|
7282301720 | ||
|
|
6e4a295ba5 | ||
|
|
b4018e8222 | ||
|
|
8cb0f3a96d | ||
|
|
fd4257300e | ||
|
|
d482fca105 | ||
|
|
4f3ab2f969 | ||
|
|
3282ac10e3 | ||
|
|
3d61d1b32f | ||
|
|
fe1c683fe1 | ||
|
|
10b701cf41 | ||
|
|
bb9cfe7a22 | ||
|
|
30fea64687 | ||
|
|
66ec594540 | ||
|
|
221deee774 | ||
|
|
d03310abbb | ||
|
|
22259558cc | ||
|
|
3268a54791 | ||
|
|
add269632b | ||
|
|
5d5a64120e |
@@ -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@v6
|
||||
|
||||
- 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@4907a6ddec9925e35a0a9e82d7399ccc52663121 # 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
|
||||
@@ -28,6 +28,7 @@ jobs:
|
||||
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@v6
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4
|
||||
@@ -47,6 +48,10 @@ jobs:
|
||||
deps:
|
||||
- '**/pyproject.toml'
|
||||
- '**/uv.lock'
|
||||
sdk_py:
|
||||
- 'libs/sdk-py/**'
|
||||
- 'libs/langgraph/langgraph/pregel/remote.py'
|
||||
- 'libs/langgraph/langgraph/pregel/_remote_run_stream.py'
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
@@ -156,6 +161,13 @@ jobs:
|
||||
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:
|
||||
@@ -166,6 +178,7 @@ jobs:
|
||||
check-sdk-methods,
|
||||
check-schema,
|
||||
integration-test,
|
||||
sdk-py-integration-test,
|
||||
]
|
||||
if: |
|
||||
always()
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "0.4.26"
|
||||
__version__ = "0.4.27"
|
||||
|
||||
@@ -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 `<package-name>:<version>` 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"(?<![a-zA-Z0-9_-])({_PACKAGES_ALT})"
|
||||
r"(?:\[[^\]]*\])?"
|
||||
r"\s*((?:(?:==|>=|<=|~=|!=|>|<)\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'(?<![a-zA-Z0-9_-])({_PACKAGES_ALT})(?:\[[^\]]*\])?\s*[,"\'\n]')
|
||||
|
||||
_EXTRAS_BRACKET_RE = re.compile(r"\[([a-zA-Z0-9_.\- ,\t]+)\]")
|
||||
|
||||
|
||||
def _appears_in_extras(content: str, pkg: str) -> 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 `<name>:<version>` 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]
|
||||
@@ -20,6 +20,7 @@ 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
|
||||
@@ -849,6 +850,41 @@ def _upload_to_gcs(signed_url: str, file_path: str, file_size: int) -> None:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _resolve_pushed_image_digest(
|
||||
runner,
|
||||
*,
|
||||
remote_image: str,
|
||||
docker_config_dir: str | None,
|
||||
verbose: bool,
|
||||
) -> str:
|
||||
"""Return ``{registry}/{repo}@sha256:<hex>`` 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,
|
||||
@@ -867,6 +903,7 @@ def _run_local_build(
|
||||
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
|
||||
@@ -1015,9 +1052,21 @@ def _run_local_build(
|
||||
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, remote_image, secrets=secrets)
|
||||
updated = client.update_deployment(
|
||||
deployment_id,
|
||||
resolved_image,
|
||||
secrets=secrets,
|
||||
tracked_packages=tracked_packages,
|
||||
)
|
||||
|
||||
return BuildResult(
|
||||
updated=updated if isinstance(updated, dict) else {},
|
||||
@@ -1039,6 +1088,7 @@ def _run_remote_build(
|
||||
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
|
||||
@@ -1069,6 +1119,7 @@ def _run_remote_build(
|
||||
secrets=secrets,
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
tracked_packages=tracked_packages,
|
||||
)
|
||||
|
||||
log_offset: str | None = None
|
||||
@@ -1553,6 +1604,15 @@ def _deploy_cmd(
|
||||
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(
|
||||
@@ -1565,6 +1625,7 @@ def _deploy_cmd(
|
||||
install_command=install_command,
|
||||
build_command=build_command,
|
||||
secrets=secrets,
|
||||
tracked_packages=tracked_packages,
|
||||
)
|
||||
else:
|
||||
build_result = _run_local_build(
|
||||
@@ -1584,6 +1645,7 @@ def _deploy_cmd(
|
||||
build_command=build_command,
|
||||
docker_build_args=docker_build_args,
|
||||
secrets=secrets,
|
||||
tracked_packages=tracked_packages,
|
||||
)
|
||||
|
||||
# -- 4. Shared wait + result --
|
||||
|
||||
@@ -122,11 +122,14 @@ class HostBackendClient:
|
||||
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(
|
||||
@@ -143,6 +146,7 @@ class HostBackendClient:
|
||||
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] = {
|
||||
@@ -152,6 +156,8 @@ class HostBackendClient:
|
||||
"langgraph_config_path": config_path,
|
||||
},
|
||||
}
|
||||
if tracked_packages:
|
||||
payload["tracked_packages"] = tracked_packages
|
||||
|
||||
source_config: dict[str, Any] = {}
|
||||
if install_command is not None:
|
||||
|
||||
@@ -23,7 +23,7 @@ dependencies = [
|
||||
path = "langgraph_cli/__init__.py"
|
||||
[project.optional-dependencies]
|
||||
inmem = [
|
||||
"langgraph-api>=0.5.35,<0.9.0 ; python_version >= '3.11'",
|
||||
"langgraph-api>=0.5.35,<0.10.0 ; python_version >= '3.11'",
|
||||
"langgraph-runtime-inmem>=0.7 ; python_version >= '3.11'",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -3,6 +3,7 @@ import io
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import click
|
||||
import httpx
|
||||
@@ -16,6 +17,7 @@ from langgraph_cli.deploy import (
|
||||
_env_without_deployment_name,
|
||||
_parse_env_from_config,
|
||||
_resolve_env_path,
|
||||
_resolve_pushed_image_digest,
|
||||
_smith_dashboard_base_url,
|
||||
normalize_image_tag,
|
||||
normalize_name,
|
||||
@@ -532,3 +534,156 @@ class TestSmithDashboardBaseUrl:
|
||||
_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()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -176,6 +178,69 @@ def test_update_deployment_no_secrets(client):
|
||||
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}
|
||||
|
||||
@@ -7,6 +7,7 @@ dependencies = [
|
||||
"langgraph>=0.6.0,<2",
|
||||
"langchain-core>=0.2.14",
|
||||
"shared",
|
||||
"langgraph-checkpoint-postgres>=3.0.0"
|
||||
]
|
||||
|
||||
[build-system]
|
||||
|
||||
@@ -6,6 +6,7 @@ requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"langgraph>=0.6.0,<2",
|
||||
"langchain-core>=1.3.3",
|
||||
"langgraph-checkpoint-postgres>=3.0.0"
|
||||
]
|
||||
|
||||
[tool.uv.workspace]
|
||||
|
||||
Generated
+53
@@ -16,6 +16,7 @@ source = { editable = "apps/agent" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langgraph" },
|
||||
{ name = "langgraph-checkpoint-postgres" },
|
||||
{ name = "shared" },
|
||||
]
|
||||
|
||||
@@ -23,6 +24,7 @@ dependencies = [
|
||||
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" },
|
||||
]
|
||||
|
||||
@@ -275,6 +277,21 @@ 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"
|
||||
@@ -446,6 +463,31 @@ 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"
|
||||
@@ -675,6 +717,15 @@ 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"
|
||||
@@ -720,12 +771,14 @@ 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]]
|
||||
|
||||
Generated
+414
-303
@@ -26,11 +26,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2026.2.25"
|
||||
version = "2026.5.20"
|
||||
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" }
|
||||
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/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
|
||||
{ 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]]
|
||||
@@ -161,11 +161,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "idna"
|
||||
version = "3.11"
|
||||
version = "3.16"
|
||||
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" }
|
||||
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/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||
{ 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]]
|
||||
@@ -191,7 +191,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-core"
|
||||
version = "1.3.3"
|
||||
version = "1.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -204,9 +204,9 @@ dependencies = [
|
||||
{ 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" }
|
||||
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/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" },
|
||||
{ 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]]
|
||||
@@ -223,7 +223,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.1.6"
|
||||
version = "1.2.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -233,53 +233,53 @@ dependencies = [
|
||||
{ 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" }
|
||||
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/71/e6/b36ecdb3ff4ba9a290708d514bae89ebbe2f554b6abbe4642acf3fddbe51/langgraph-1.1.6-py3-none-any.whl", hash = "sha256:fdbf5f54fa5a5a4c4b09b7b5e537f1b2fa283d2f0f610d3457ddeecb479458b9", size = 169755, upload-time = "2026-04-03T19:01:30.686Z" },
|
||||
{ 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.0.1"
|
||||
version = "4.1.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" }
|
||||
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/65/4c/09a4a0c42f5d2fc38d6c4d67884788eff7fd2cfdf367fdf7033de908b4c0/langgraph_checkpoint-4.0.1-py3-none-any.whl", hash = "sha256:e3adcd7a0e0166f3b48b8cf508ce0ea366e7420b5a73aa81289888727769b034", size = 50453, upload-time = "2026-02-27T21:06:14.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/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.0.9"
|
||||
version = "1.1.0"
|
||||
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" }
|
||||
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/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" },
|
||||
{ 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.13"
|
||||
version = "0.3.15"
|
||||
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" }
|
||||
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/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" },
|
||||
{ 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.0"
|
||||
version = "0.8.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -292,77 +292,77 @@ dependencies = [
|
||||
{ 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" }
|
||||
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/f3/e1/a4be2e696c9473bb53298df398237da5674704d781d4b748ed35aeef592a/langsmith-0.8.0-py3-none-any.whl", hash = "sha256:12cc4bc5622b835a6d841964d6034df3617bdb912dae0c1381fd0a68a9b3a3ef", size = 393268, upload-time = "2026-04-30T22:13:05.56Z" },
|
||||
{ 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.8"
|
||||
version = "3.11.9"
|
||||
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" }
|
||||
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/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" },
|
||||
{ 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]]
|
||||
@@ -415,16 +415,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "packaging"
|
||||
version = "26.0"
|
||||
version = "26.2"
|
||||
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" }
|
||||
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/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" },
|
||||
{ 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.12.5"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -432,106 +432,111 @@ dependencies = [
|
||||
{ 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" }
|
||||
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/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
{ 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.41.5"
|
||||
version = "2.46.4"
|
||||
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" }
|
||||
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/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" },
|
||||
{ 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]]
|
||||
@@ -591,7 +596,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "requests"
|
||||
version = "2.33.1"
|
||||
version = "2.34.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "certifi" },
|
||||
@@ -599,9 +604,9 @@ dependencies = [
|
||||
{ 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" }
|
||||
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/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
||||
{ 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]]
|
||||
@@ -672,134 +677,240 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid-utils"
|
||||
version = "0.14.1"
|
||||
version = "0.16.0"
|
||||
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" }
|
||||
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/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" },
|
||||
{ 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.6.0"
|
||||
version = "3.7.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" }
|
||||
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/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" },
|
||||
{ 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]]
|
||||
|
||||
Generated
+1
-1
@@ -1094,7 +1094,7 @@ test = [
|
||||
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,<0.9.0" },
|
||||
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.10.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" },
|
||||
|
||||
@@ -79,6 +79,34 @@ def patch_checkpoint_map(
|
||||
return config
|
||||
|
||||
|
||||
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.
|
||||
|
||||
@@ -113,34 +141,9 @@ def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig:
|
||||
else:
|
||||
base[key] = value
|
||||
elif key == "callbacks":
|
||||
base_callbacks = base.get("callbacks")
|
||||
# callbacks can be either None, list[handler] or manager
|
||||
# so merging two callbacks values has 6 cases
|
||||
if isinstance(value, list):
|
||||
if base_callbacks is None:
|
||||
base["callbacks"] = value.copy()
|
||||
elif isinstance(base_callbacks, list):
|
||||
base["callbacks"] = base_callbacks + value
|
||||
else:
|
||||
# base_callbacks is a manager
|
||||
mngr = base_callbacks.copy()
|
||||
for callback in value:
|
||||
mngr.add_handler(callback, inherit=True)
|
||||
base["callbacks"] = mngr
|
||||
elif isinstance(value, BaseCallbackManager):
|
||||
# value is a manager
|
||||
if base_callbacks is None:
|
||||
base["callbacks"] = value.copy()
|
||||
elif isinstance(base_callbacks, list):
|
||||
mngr = value.copy()
|
||||
for callback in base_callbacks:
|
||||
mngr.add_handler(callback, inherit=True)
|
||||
base["callbacks"] = mngr
|
||||
else:
|
||||
# base_callbacks is also a manager
|
||||
base["callbacks"] = base_callbacks.merge(value)
|
||||
else:
|
||||
raise NotImplementedError
|
||||
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"]
|
||||
@@ -309,7 +312,40 @@ def ensure_config(*configs: RunnableConfig | None) -> RunnableConfig:
|
||||
for k, v in config.items():
|
||||
if _is_not_empty(v) and k in CONFIG_KEYS:
|
||||
if k == CONF:
|
||||
empty[k] = cast(dict, v).copy()
|
||||
# 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":
|
||||
# Shallow-merge metadata dicts across configs so values
|
||||
# bound via with_config(...) (e.g. user_id) are preserved
|
||||
# when later configs supply other metadata keys.
|
||||
existing = empty.get("metadata")
|
||||
empty["metadata"] = (
|
||||
{**cast(dict, existing), **cast(dict, v)}
|
||||
if existing
|
||||
else cast(dict, v).copy()
|
||||
)
|
||||
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():
|
||||
@@ -366,3 +402,17 @@ _PROPAGATE_TO_METADATA = frozenset(
|
||||
"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
|
||||
|
||||
@@ -21,6 +21,7 @@ __all__ = (
|
||||
"InvalidUpdateError",
|
||||
"GraphBubbleUp",
|
||||
"GraphInterrupt",
|
||||
"NodeCancelledError",
|
||||
"NodeError",
|
||||
"NodeInterrupt",
|
||||
"NodeTimeoutError",
|
||||
@@ -164,6 +165,28 @@ class NodeError:
|
||||
"""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.
|
||||
|
||||
|
||||
@@ -115,6 +115,7 @@ from langgraph.pregel._io import (
|
||||
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 (
|
||||
@@ -447,6 +448,14 @@ class PregelLoop:
|
||||
|
||||
# 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,
|
||||
|
||||
@@ -11,9 +11,11 @@ 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
|
||||
@@ -142,9 +144,8 @@ class StreamMessagesHandler(BaseCallbackHandler, _StreamingCallbackHandler):
|
||||
]
|
||||
if not self.subgraphs and len(ns) > 0 and ns != self.parent_ns:
|
||||
return
|
||||
if tags:
|
||||
if filtered_tags := [t for t in tags if not t.startswith("seq:step")]:
|
||||
metadata["tags"] = filtered_tags
|
||||
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(
|
||||
@@ -405,3 +406,56 @@ class StreamMessagesHandlerV2(StreamMessagesHandler, _V2StreamingCallbackHandler
|
||||
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
|
||||
|
||||
@@ -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:<name>`).
|
||||
|
||||
`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.
|
||||
@@ -37,13 +37,23 @@ from langgraph._internal._constants import (
|
||||
)
|
||||
from langgraph._internal._runnable import create_task_in_config_context
|
||||
from langgraph._internal._timeout import sync_timeout_unsupported
|
||||
from langgraph.errors import GraphBubbleUp, NodeTimeoutError, ParentCommand
|
||||
from langgraph.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:
|
||||
@@ -302,6 +312,28 @@ class _IdleProgressCallbackHandler(BaseCallbackHandler):
|
||||
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):
|
||||
@@ -600,6 +632,12 @@ def run_with_retry(
|
||||
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}'")
|
||||
@@ -736,6 +774,24 @@ async def arun_with_retry(
|
||||
# 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:
|
||||
|
||||
@@ -6,9 +6,13 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import CheckpointMetadata, PendingWrite
|
||||
from langgraph.checkpoint.base import (
|
||||
EXCLUDED_METADATA_KEYS,
|
||||
CheckpointMetadata,
|
||||
PendingWrite,
|
||||
)
|
||||
|
||||
from langgraph._internal._config import patch_checkpoint_map
|
||||
from langgraph._internal._config import filter_to_user_tags, patch_checkpoint_map
|
||||
from langgraph._internal._constants import (
|
||||
CONF,
|
||||
CONFIG_KEY_CHECKPOINT_NS,
|
||||
@@ -40,12 +44,31 @@ def map_debug_tasks(tasks: Iterable[PregelExecutableTask]) -> Iterator[TaskPaylo
|
||||
if task.config is not None and TAG_HIDDEN in task.config.get("tags", []):
|
||||
continue
|
||||
|
||||
yield {
|
||||
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:
|
||||
|
||||
@@ -55,6 +55,10 @@ from langgraph._internal._constants import (
|
||||
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,
|
||||
@@ -80,6 +84,8 @@ _CONF_DROPLIST = frozenset(
|
||||
),
|
||||
)
|
||||
|
||||
_V3_SUPPORTED_KWARGS = frozenset({"metadata", "headers"})
|
||||
|
||||
|
||||
def _sanitize_config_value(v: Any) -> Any:
|
||||
"""Recursively sanitize a config value to ensure it contains only primitives."""
|
||||
@@ -186,6 +192,34 @@ class RemoteGraph(PregelProtocol):
|
||||
)
|
||||
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)
|
||||
@@ -996,21 +1030,104 @@ class RemoteGraph(PregelProtocol):
|
||||
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"],
|
||||
include_names: Sequence[All] | None = None,
|
||||
include_types: Sequence[All] | None = None,
|
||||
include_tags: Sequence[All] | None = None,
|
||||
exclude_names: Sequence[All] | None = None,
|
||||
exclude_types: Sequence[All] | None = None,
|
||||
exclude_tags: Sequence[All] | 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,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
raise NotImplementedError
|
||||
) -> 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(
|
||||
|
||||
@@ -35,7 +35,7 @@ class ProtocolEvent(TypedDict):
|
||||
"""
|
||||
|
||||
type: Literal["event"]
|
||||
eventId: NotRequired[str]
|
||||
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
|
||||
|
||||
@@ -9,7 +9,7 @@ from langchain_core.language_models.chat_model_stream import (
|
||||
ChatModelStream,
|
||||
)
|
||||
from langchain_core.messages import AIMessageChunk, BaseMessage, ToolMessage
|
||||
from langchain_protocol.protocol import MessagesData
|
||||
from langchain_protocol.protocol import LifecycleCause, MessagesData
|
||||
from typing_extensions import NotRequired, TypedDict
|
||||
|
||||
from langgraph.errors import GraphDrained, GraphInterrupt
|
||||
@@ -366,6 +366,7 @@ class LifecyclePayload(TypedDict, total=False):
|
||||
namespace: list[str]
|
||||
graph_name: NotRequired[str]
|
||||
trigger_call_id: NotRequired[str]
|
||||
cause: NotRequired[LifecycleCause]
|
||||
error: NotRequired[str]
|
||||
|
||||
|
||||
@@ -406,6 +407,18 @@ class _TasksLifecycleBase(StreamTransformer):
|
||||
# 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:<task_id>` shares this task_id, so a subagent recovers the tool
|
||||
# call that spawned it (cross-payload).
|
||||
self._pending_tool_calls: dict[str, str] = {}
|
||||
|
||||
# --- Template-method hooks (subclass overrides) ---
|
||||
|
||||
@@ -418,6 +431,8 @@ class _TasksLifecycleBase(StreamTransformer):
|
||||
ns: tuple[str, ...],
|
||||
graph_name: str | None,
|
||||
trigger_call_id: str | None,
|
||||
*,
|
||||
cause: LifecycleCause | None = None,
|
||||
) -> None:
|
||||
"""Fired once per discovered namespace (first observed task event)."""
|
||||
raise NotImplementedError
|
||||
@@ -443,18 +458,81 @@ class _TasksLifecycleBase(StreamTransformer):
|
||||
if "result" in data:
|
||||
self._handle_task_result(ns, data)
|
||||
else:
|
||||
self._handle_task_start(ns)
|
||||
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 _handle_task_start(self, ns: tuple[str, ...]) -> None:
|
||||
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:<task_id>` 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)
|
||||
graph_name, trigger_call_id = _parse_ns_segment(ns[-1])
|
||||
self._on_started(ns, graph_name or None, trigger_call_id)
|
||||
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)}
|
||||
self._on_started(ns, graph_name, trigger_call_id, cause=cause)
|
||||
if trigger_call_id is not None:
|
||||
self._open[ns] = trigger_call_id
|
||||
|
||||
@@ -553,6 +631,8 @@ class LifecycleTransformer(_TasksLifecycleBase):
|
||||
ns: tuple[str, ...],
|
||||
graph_name: str | None,
|
||||
trigger_call_id: str | None,
|
||||
*,
|
||||
cause: LifecycleCause | None = None,
|
||||
) -> None:
|
||||
if trigger_call_id is None:
|
||||
# Without a task id we can't correlate a parent-result
|
||||
@@ -563,6 +643,8 @@ class LifecycleTransformer(_TasksLifecycleBase):
|
||||
if graph_name:
|
||||
payload["graph_name"] = graph_name
|
||||
payload["trigger_call_id"] = trigger_call_id
|
||||
if cause is not None:
|
||||
payload["cause"] = cause
|
||||
self._channel.push(payload)
|
||||
|
||||
def _on_terminal(
|
||||
@@ -625,6 +707,8 @@ class SubgraphTransformer(_TasksLifecycleBase):
|
||||
ns: tuple[str, ...],
|
||||
graph_name: str | None,
|
||||
trigger_call_id: str | None,
|
||||
*,
|
||||
cause: LifecycleCause | None = None,
|
||||
) -> None:
|
||||
if self._mux is None:
|
||||
return
|
||||
@@ -633,6 +717,10 @@ class SubgraphTransformer(_TasksLifecycleBase):
|
||||
except RuntimeError:
|
||||
return
|
||||
handle_cls = AsyncSubgraphRunStream if child_mux.is_async else SubgraphRunStream
|
||||
# `cause` is intentionally ignored here: it is a wire/lifecycle-channel
|
||||
# concern (carried on `LifecyclePayload`), not something the in-process
|
||||
# subgraph navigation handle exposes. The argument is accepted only to
|
||||
# keep the `_on_started` template signature uniform across transformers.
|
||||
handle = handle_cls(
|
||||
mux=child_mux,
|
||||
path=ns,
|
||||
@@ -737,7 +825,12 @@ class SubgraphTransformer(_TasksLifecycleBase):
|
||||
for child_ns, status, error in self._pop_terminal_transitions(ns, data):
|
||||
await self._aon_terminal(child_ns, status, error)
|
||||
else:
|
||||
self._handle_task_start(ns)
|
||||
# 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
|
||||
|
||||
@@ -150,6 +150,16 @@ class TaskPayload(TypedDict):
|
||||
"""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):
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
@@ -26,7 +26,7 @@ classifiers = [
|
||||
dependencies = [
|
||||
"langchain-core>=1.4.0,<2",
|
||||
"langgraph-checkpoint>=4.1.0,<5.0.0",
|
||||
"langgraph-sdk>=0.3.0,<0.4.0",
|
||||
"langgraph-sdk>=0.4.1,<0.5.0",
|
||||
"langgraph-prebuilt>=1.1.0,<1.2.0",
|
||||
"xxhash>=3.5.0",
|
||||
"pydantic>=2.7.4",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import pytest
|
||||
from langchain_core.callbacks import AsyncCallbackManager
|
||||
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
|
||||
|
||||
@@ -17,3 +18,99 @@ 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"
|
||||
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"]}
|
||||
@@ -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")
|
||||
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
import operator
|
||||
import sys
|
||||
import threading
|
||||
@@ -35,7 +36,13 @@ 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, NodeError, NodeTimeoutError, ParentCommand
|
||||
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
|
||||
@@ -63,6 +70,18 @@ NEEDS_CONTEXTVARS = pytest.mark.skipif(
|
||||
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."""
|
||||
@@ -2802,3 +2821,123 @@ def test_error_handler_resumes_after_crash_multiple_nodes():
|
||||
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"
|
||||
|
||||
@@ -35,20 +35,25 @@ def _tasks_start(
|
||||
*,
|
||||
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": {
|
||||
"id": task_id,
|
||||
"name": name,
|
||||
"input": None,
|
||||
"triggers": [],
|
||||
},
|
||||
"data": data,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -404,3 +409,272 @@ def test_stream_events_v3_with_nested_parent_ns_scopes_lifecycle() -> None:
|
||||
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:<taskid>",) with
|
||||
# metadata["lc_agent_name"] == "weather_agent", sharing the SAME <taskid>
|
||||
# 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"}
|
||||
|
||||
@@ -15,12 +15,14 @@ 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,
|
||||
)
|
||||
@@ -427,3 +429,128 @@ def test_callback_manager_copies_configurable_ids_to_tracing_metadata() -> None:
|
||||
"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_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_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
|
||||
|
||||
Generated
+70
-5
@@ -1370,19 +1370,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.14"
|
||||
version = "0.0.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/bf/efb5e2ed832e4d6d45590e25a9e5191986b291b543bc6a807b48bee070b0/langchain_protocol-0.0.14.tar.gz", hash = "sha256:bc1e8553122e6ede310280462d5813023a172ff2785ccbbdec54d43f3a15e5f2", size = 5862, upload-time = "2026-04-29T16:40:18.657Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e9/06c47ecb2aff08f83dfa30058da3bf86be64862c19569043ed5331bbeecd/langchain_protocol-0.0.14-py3-none-any.whl", hash = "sha256:ffc35089779bd8ca217015180cef5e660fc3b074efdaa0f2e95df73583f1a047", size = 6984, upload-time = "2026-04-29T16:40:17.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -1721,7 +1721,7 @@ inmem = [
|
||||
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,<0.9.0" },
|
||||
{ name = "langgraph-api", marker = "python_full_version >= '3.11' and extra == 'inmem'", specifier = ">=0.5.35,<0.10.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" },
|
||||
@@ -1828,13 +1828,19 @@ 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,<16" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -4007,6 +4013,65 @@ 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 = "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 = "widgetsnbextension"
|
||||
version = "4.0.15"
|
||||
|
||||
Generated
+69
-4
@@ -273,19 +273,19 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langchain-protocol"
|
||||
version = "0.0.14"
|
||||
version = "0.0.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/bf/efb5e2ed832e4d6d45590e25a9e5191986b291b543bc6a807b48bee070b0/langchain_protocol-0.0.14.tar.gz", hash = "sha256:bc1e8553122e6ede310280462d5813023a172ff2785ccbbdec54d43f3a15e5f2", size = 5862, upload-time = "2026-04-29T16:40:18.657Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4f/24/9777489d6fbbee64af0c8f96d4f840239c408cf694f3394672807dafc490/langchain_protocol-0.0.15.tar.gz", hash = "sha256:9ab2d11ee73944754f10e037e717098d3a6796f0e58afa9cadda6154e7655ade", size = 5862, upload-time = "2026-05-01T22:30:04.748Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/e9/06c47ecb2aff08f83dfa30058da3bf86be64862c19569043ed5331bbeecd/langchain_protocol-0.0.14-py3-none-any.whl", hash = "sha256:ffc35089779bd8ca217015180cef5e660fc3b074efdaa0f2e95df73583f1a047", size = 6984, upload-time = "2026-04-29T16:40:17.841Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/7a/9c97a7b9cbe4c5dc6a44cdb1545450c28f0c8ce89b9c1f0ee7fbad896263/langchain_protocol-0.0.15-py3-none-any.whl", hash = "sha256:461eb794358f83d5e42635a5797799ffec7b4702314e34edf73ac21e75d3ef79", size = 6982, upload-time = "2026-05-01T22:30:03.877Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.2.1"
|
||||
version = "1.2.2"
|
||||
source = { editable = "../langgraph" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -597,13 +597,19 @@ 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,<16" },
|
||||
]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
@@ -1558,6 +1564,65 @@ wheels = [
|
||||
{ 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.6.0"
|
||||
|
||||
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -33,3 +33,43 @@ 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
|
||||
```
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
# 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"
|
||||
|
||||
# Project graphs + registration config.
|
||||
COPY graph/ /app/graph/
|
||||
COPY langgraph.json /app/langgraph.json
|
||||
@@ -0,0 +1,91 @@
|
||||
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,
|
||||
# so we only add graph deps (deepagents) and the graph files on top.
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
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"}'
|
||||
|
||||
# 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
|
||||
@@ -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"]
|
||||
@@ -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",
|
||||
)
|
||||
@@ -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],
|
||||
)
|
||||
@@ -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",
|
||||
)
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$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"
|
||||
},
|
||||
"env": {}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -3,6 +3,6 @@ from langgraph_sdk.client import get_client, get_sync_client
|
||||
from langgraph_sdk.encryption import Encryption
|
||||
from langgraph_sdk.encryption.types import EncryptionContext
|
||||
|
||||
__version__ = "0.3.15"
|
||||
__version__ = "0.4.2"
|
||||
|
||||
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,10 +2,12 @@
|
||||
|
||||
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,
|
||||
@@ -734,6 +736,52 @@ class ThreadsClient:
|
||||
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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,11 +2,13 @@
|
||||
|
||||
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,
|
||||
@@ -722,6 +724,41 @@ class SyncThreadsClient:
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Stream module for LangGraph SDK v3."""
|
||||
|
||||
from langchain_protocol import (
|
||||
Channel,
|
||||
Event,
|
||||
Namespace,
|
||||
SubscribeParams,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Channel",
|
||||
"Event",
|
||||
"Namespace",
|
||||
"SubscribeParams",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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:<run_id>` 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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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())
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -11,7 +11,13 @@ requires-python = ">=3.10"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
license-files = ['LICENSE']
|
||||
dependencies = ["httpx>=0.25.2", "orjson>=3.11.5"]
|
||||
dependencies = [
|
||||
"httpx>=0.25.2",
|
||||
"orjson>=3.11.5",
|
||||
"langchain-protocol>=0.0.15",
|
||||
"langchain-core>=1.4.0,<2",
|
||||
"websockets>=14,<16",
|
||||
]
|
||||
|
||||
[tool.hatch.version]
|
||||
path = "langgraph_sdk/__init__.py"
|
||||
@@ -47,8 +53,11 @@ dev = [
|
||||
include = ["langgraph_sdk"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
addopts = "--strict-markers --strict-config --durations=5 -vv"
|
||||
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']
|
||||
@@ -79,7 +88,13 @@ ignore = [
|
||||
"B904", # raise without from inside except (sometimes intentional)
|
||||
"SIM102", # nested if statements (sometimes clearer)
|
||||
]
|
||||
per-file-ignores = { "tests/**" = ["S101", "B017"] }
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"""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"
|
||||
|
||||
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()
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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}"
|
||||
@@ -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=<cursor>`) 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']})"
|
||||
)
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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 [],
|
||||
},
|
||||
)
|
||||
@@ -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}"})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user