mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-10-01 14:05:10 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3129e521ea | ||
|
|
4be610c6bc | ||
|
|
98b10ba0ff | ||
|
|
ffe4e8cd6d | ||
|
|
f75add0ee9 | ||
|
|
902be1eb2b | ||
|
|
3b969324db | ||
|
|
e43f0f5f1e | ||
|
|
8ccdedcc1b | ||
|
|
7ea31e5e45 | ||
|
|
313e0e7e0a | ||
|
|
b9919ace32 | ||
|
|
4217373227 | ||
|
|
65ab10f017 | ||
|
|
eb69f67b65 | ||
|
|
c0279f0910 | ||
|
|
f5804a5bf5 | ||
|
|
07b33185ea | ||
|
|
7daa3ab49d | ||
|
|
e868c3ccfd | ||
|
|
bdb85b5aa8 | ||
|
|
1211af45b1 | ||
|
|
1afaca35a0 | ||
|
|
49cce0ca85 | ||
|
|
19273fa88b | ||
|
|
ed384f3a12 | ||
|
|
aa742fb31e | ||
|
|
b58044ac7a | ||
|
|
daa514a988 | ||
|
|
022043a679 | ||
|
|
d7b99cc3ab | ||
|
|
b19edd783b | ||
|
|
c81c13533e | ||
|
|
448a763779 | ||
|
|
230927fb3a | ||
|
|
e539ac122f | ||
|
|
ff4529380d | ||
|
|
c0b884e6d8 | ||
|
|
3299a1f563 |
@@ -41,7 +41,7 @@ def test(config: pathlib.Path, port: int, tag: str, verbose: bool):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Compose up with wait (implies detach), similar to `langgraph up --wait`
|
# Compose up with wait (implies detach), similar to `langgraph up --wait`
|
||||||
args_up = [*args, "up", "--remove-orphans", "--wait"]
|
args_up = [*args, "up", "--remove-orphans", "--wait", "--wait-timeout", "180"]
|
||||||
|
|
||||||
compose_cmd = ["docker", "compose"]
|
compose_cmd = ["docker", "compose"]
|
||||||
if capabilities.compose_type == "standalone":
|
if capabilities.compose_type == "standalone":
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ jobs:
|
|||||||
if [ -f ../.env ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> ../.env; fi
|
if [ -f ../.env ]; then echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> ../.env; fi
|
||||||
# Run the integration test using the built tag
|
# Run the integration test using the built tag
|
||||||
REPO_ROOT=$(git rev-parse --show-toplevel)
|
REPO_ROOT=$(git rev-parse --show-toplevel)
|
||||||
timeout 60 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}
|
timeout --kill-after=30 300 python "$REPO_ROOT/.github/scripts/run_langgraph_cli_test.py" -t ${{ matrix.example.tag }}
|
||||||
|
|
||||||
- name: Build JS service
|
- name: Build JS service
|
||||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||||
@@ -98,7 +98,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cp apps/agent/.env.example apps/agent/.env
|
cp apps/agent/.env.example apps/agent/.env
|
||||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env
|
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> apps/agent/.env
|
||||||
timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
|
timeout --kill-after=30 300 python ../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-g -c apps/agent/langgraph.json
|
||||||
|
|
||||||
- name: Build prerelease reqs service
|
- name: Build prerelease reqs service
|
||||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||||
@@ -113,11 +113,11 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cp ../.env.example .env
|
cp ../.env.example .env
|
||||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||||
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
|
timeout --kill-after=30 300 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-h
|
||||||
echo "Finished starting up langgraph-test-h"
|
echo "Finished starting up langgraph-test-h"
|
||||||
LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);")
|
LANGGRAPH_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langgraph'); print(v);")
|
||||||
if [ "$LANGGRAPH_VERSION" != "1.1.5" ]; then
|
if [ "$LANGGRAPH_VERSION" != "1.2.6" ]; then
|
||||||
echo "LANGGRAPH_VERSION != 1.1.5; $LANGGRAPH_VERSION"
|
echo "LANGGRAPH_VERSION != 1.2.6; $LANGGRAPH_VERSION"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
|
LANGCHAIN_OPENAI_VERSION=$(docker run --rm --entrypoint "" langgraph-test-h python -c "import sys; from importlib.metadata import version; v = version('langchain-openai'); print(v);")
|
||||||
@@ -150,7 +150,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||||
timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-simple
|
timeout --kill-after=30 300 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-simple
|
||||||
|
|
||||||
- name: Build uv monorepo service
|
- name: Build uv monorepo service
|
||||||
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
if: ${{ (steps.changed-files.outputs.all || github.event_name == 'workflow_dispatch') &&matrix.example.name == 'A' }}
|
||||||
@@ -165,4 +165,4 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
echo "LANGSMITH_API_KEY=${{ secrets.LANGSMITH_API_KEY }}" >> .env
|
||||||
timeout 60 python ../../../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-monorepo
|
timeout --kill-after=30 300 python ../../../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-uv-monorepo
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ on:
|
|||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- '.github/workflows/deploy-redirects.yml'
|
- '.github/workflows/deploy-redirects.yml'
|
||||||
|
# llms.txt is fetched from docs.langchain.com at build time, so redeploy on a
|
||||||
|
# schedule to pick up docs changes that never touch this repo.
|
||||||
|
schedule:
|
||||||
|
- cron: '17 6 * * 1'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -139,23 +139,10 @@ jobs:
|
|||||||
echo EOF
|
echo EOF
|
||||||
} >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
test-pypi-publish:
|
|
||||||
needs:
|
|
||||||
- build
|
|
||||||
- release-notes
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
id-token: write
|
|
||||||
uses: ./.github/workflows/_test_release.yml
|
|
||||||
with:
|
|
||||||
working-directory: ${{ inputs.working-directory }}
|
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
pre-release-checks:
|
pre-release-checks:
|
||||||
needs:
|
needs:
|
||||||
- build
|
- build
|
||||||
- release-notes
|
- release-notes
|
||||||
- test-pypi-publish
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -180,31 +167,20 @@ jobs:
|
|||||||
enable-cache: false
|
enable-cache: false
|
||||||
working-directory: ${{ inputs.working-directory }}
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
|
||||||
- name: Import published package
|
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||||
|
with:
|
||||||
|
name: dist
|
||||||
|
path: ${{ inputs.working-directory }}/dist/
|
||||||
|
|
||||||
|
- name: Import dist package
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ${{ inputs.working-directory }}
|
working-directory: ${{ inputs.working-directory }}
|
||||||
env:
|
env:
|
||||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||||
VERSION: ${{ needs.build.outputs.version }}
|
VERSION: ${{ needs.build.outputs.version }}
|
||||||
# Here we use:
|
# Install directly from the locally-built wheel (no index resolution needed).
|
||||||
# - The default regular PyPI index as the *primary* index, meaning
|
|
||||||
# that it takes priority (https://pypi.org/simple)
|
|
||||||
# - The test PyPI index as an extra index, so that any dependencies that
|
|
||||||
# are not found on test PyPI can be resolved and installed anyway.
|
|
||||||
# (https://test.pypi.org/simple). This will include the PKG_NAME==VERSION
|
|
||||||
# package because VERSION will not have been uploaded to regular PyPI yet.
|
|
||||||
# - attempt install again after 5 seconds if it fails because there is
|
|
||||||
# sometimes a delay in availability on test pypi
|
|
||||||
run: |
|
run: |
|
||||||
uv run pip install \
|
uv run pip install dist/*.whl
|
||||||
--extra-index-url https://test.pypi.org/simple/ \
|
|
||||||
"$PKG_NAME==$VERSION" || \
|
|
||||||
( \
|
|
||||||
sleep 5 && \
|
|
||||||
uv run pip install \
|
|
||||||
--extra-index-url https://test.pypi.org/simple/ \
|
|
||||||
"$PKG_NAME==$VERSION" \
|
|
||||||
)
|
|
||||||
|
|
||||||
if [[ "$PKG_NAME" == *prebuilt* ]]; then
|
if [[ "$PKG_NAME" == *prebuilt* ]]; then
|
||||||
uv run pip install langgraph
|
uv run pip install langgraph
|
||||||
@@ -226,7 +202,7 @@ jobs:
|
|||||||
run: uv sync --group test
|
run: uv sync --group test
|
||||||
working-directory: ${{ inputs.working-directory }}
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
|
||||||
# Overwrite the local version of the package with the test PyPI version.
|
# Overwrite the local version of the package with the built version
|
||||||
- name: Import published package (again)
|
- name: Import published package (again)
|
||||||
working-directory: ${{ inputs.working-directory }}
|
working-directory: ${{ inputs.working-directory }}
|
||||||
shell: bash
|
shell: bash
|
||||||
@@ -234,14 +210,25 @@ jobs:
|
|||||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||||
VERSION: ${{ needs.build.outputs.version }}
|
VERSION: ${{ needs.build.outputs.version }}
|
||||||
run: |
|
run: |
|
||||||
uv run pip install \
|
uv run pip install dist/*.whl
|
||||||
--extra-index-url https://test.pypi.org/simple/ \
|
|
||||||
"$PKG_NAME==$VERSION"
|
|
||||||
|
|
||||||
- name: Run unit tests
|
- name: Run unit tests
|
||||||
run: make test
|
run: make test
|
||||||
working-directory: ${{ inputs.working-directory }}
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
|
||||||
|
test-pypi-publish:
|
||||||
|
needs:
|
||||||
|
- build
|
||||||
|
- release-notes
|
||||||
|
- pre-release-checks
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
id-token: write
|
||||||
|
uses: ./.github/workflows/_test_release.yml
|
||||||
|
with:
|
||||||
|
working-directory: ${{ inputs.working-directory }}
|
||||||
|
secrets: inherit
|
||||||
|
|
||||||
publish:
|
publish:
|
||||||
needs:
|
needs:
|
||||||
- build
|
- build
|
||||||
|
|||||||
@@ -1,65 +0,0 @@
|
|||||||
# AGENTS Instructions
|
|
||||||
|
|
||||||
This repository is a monorepo. Each library lives in a subdirectory under `libs/`.
|
|
||||||
|
|
||||||
<corridor>
|
|
||||||
|
|
||||||
## Corridor security analysis
|
|
||||||
|
|
||||||
Before generating or modifying code, create a plan and use Corridor's `analyzePlan` tool to analyze it. Apply the resulting security guidance before writing code.
|
|
||||||
|
|
||||||
</corridor>
|
|
||||||
|
|
||||||
When you modify code in any library, run the following commands in that library's directory before creating a pull request:
|
|
||||||
|
|
||||||
- `make format` – run code formatters
|
|
||||||
- `make lint` – run the linter
|
|
||||||
- `make test` – execute the test suite
|
|
||||||
|
|
||||||
To run a particular test file or to pass additional pytest options you can specify the `TEST` variable:
|
|
||||||
|
|
||||||
```
|
|
||||||
TEST=path/to/test.py make test
|
|
||||||
```
|
|
||||||
|
|
||||||
Other pytest arguments can also be supplied inside the `TEST` variable.
|
|
||||||
|
|
||||||
## Libraries
|
|
||||||
|
|
||||||
The repository contains several Python and JavaScript/TypeScript libraries.
|
|
||||||
Below is a high-level overview:
|
|
||||||
|
|
||||||
- **checkpoint** – base interfaces for LangGraph checkpointers.
|
|
||||||
- **checkpoint-postgres** – Postgres implementation of the checkpoint saver.
|
|
||||||
- **checkpoint-sqlite** – SQLite implementation of the checkpoint saver.
|
|
||||||
- **cli** – official command-line interface for LangGraph.
|
|
||||||
- **langgraph** – core framework for building stateful, multi-actor agents.
|
|
||||||
- **prebuilt** – high-level APIs for creating and running agents and tools.
|
|
||||||
- **sdk-js** – JS/TS SDK for interacting with the LangGraph REST API.
|
|
||||||
- **sdk-py** – Python SDK for the LangGraph Server API.
|
|
||||||
|
|
||||||
### Dependency map
|
|
||||||
|
|
||||||
The diagram below lists downstream libraries for each production dependency as
|
|
||||||
declared in that library's `pyproject.toml` (or `package.json`).
|
|
||||||
|
|
||||||
```text
|
|
||||||
checkpoint
|
|
||||||
├── checkpoint-postgres
|
|
||||||
├── checkpoint-sqlite
|
|
||||||
├── prebuilt
|
|
||||||
└── langgraph
|
|
||||||
|
|
||||||
prebuilt
|
|
||||||
└── langgraph
|
|
||||||
|
|
||||||
sdk-py
|
|
||||||
├── langgraph
|
|
||||||
└── cli
|
|
||||||
|
|
||||||
sdk-js (standalone)
|
|
||||||
```
|
|
||||||
|
|
||||||
Changes to a library may impact all of its dependents shown above.
|
|
||||||
|
|
||||||
- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments.
|
|
||||||
@@ -12,13 +12,26 @@ which is SEO-friendly and treated similarly to 301 redirects by Google.
|
|||||||
To add new redirects, simply edit redirects.json and re-run this script.
|
To add new redirects, simply edit redirects.json and re-run this script.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import http.client
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
# Default fallback URL for any path not in the redirect map
|
# Default fallback URL for any path not in the redirect map
|
||||||
DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview"
|
DEFAULT_REDIRECT = "https://docs.langchain.com/oss/python/langgraph/overview"
|
||||||
|
|
||||||
|
# The docs site regenerates this index on every deploy, so fetching it here
|
||||||
|
# keeps the published llms.txt from drifting. The URL is a hardcoded constant,
|
||||||
|
# never built from input, and both it and the post-redirect URL are checked
|
||||||
|
# against ALLOWED_LLMS_HOST before anything is read.
|
||||||
|
CANONICAL_LLMS_URL = "https://docs.langchain.com/oss/python/langgraph/llms.txt"
|
||||||
|
ALLOWED_LLMS_HOST = "docs.langchain.com"
|
||||||
|
LLMS_FETCH_TIMEOUT = 30
|
||||||
|
LLMS_MAX_BYTES = 1_000_000
|
||||||
|
|
||||||
HTML_TEMPLATE = """<!doctype html>
|
HTML_TEMPLATE = """<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
@@ -75,6 +88,64 @@ CATCHALL_404_TEMPLATE = """<!doctype html>
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def is_allowed_llms_url(url):
|
||||||
|
"""Return True if url is HTTPS on the one host we accept content from."""
|
||||||
|
parsed = urllib.parse.urlsplit(url)
|
||||||
|
return parsed.scheme == "https" and parsed.hostname == ALLOWED_LLMS_HOST
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_canonical_llms_txt():
|
||||||
|
"""Return the published LangGraph index, or None if it cannot be used.
|
||||||
|
|
||||||
|
Returning None leaves the caller on the committed docs/llms.txt, so a
|
||||||
|
docs.langchain.com outage degrades to a stale file rather than a broken
|
||||||
|
deploy or a published error page.
|
||||||
|
"""
|
||||||
|
if not is_allowed_llms_url(CANONICAL_LLMS_URL):
|
||||||
|
print(f"Refusing to fetch {CANONICAL_LLMS_URL}: host not allowed")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen( # noqa: S310 - constant, allowlisted URL
|
||||||
|
CANONICAL_LLMS_URL, timeout=LLMS_FETCH_TIMEOUT
|
||||||
|
) as response:
|
||||||
|
# urlopen follows redirects, so re-check where it actually landed.
|
||||||
|
if not is_allowed_llms_url(response.url):
|
||||||
|
print(f"Refusing {CANONICAL_LLMS_URL}: redirected to {response.url}")
|
||||||
|
return None
|
||||||
|
body = response.read(LLMS_MAX_BYTES + 1)
|
||||||
|
# A connection dropped mid-body raises http.client.IncompleteRead, which
|
||||||
|
# descends from HTTPException rather than OSError, so catching only the
|
||||||
|
# urllib and OS errors would let it escape and fail the whole deploy.
|
||||||
|
except (
|
||||||
|
urllib.error.URLError,
|
||||||
|
http.client.HTTPException,
|
||||||
|
TimeoutError,
|
||||||
|
OSError,
|
||||||
|
) as exc:
|
||||||
|
print(f"Could not fetch {CANONICAL_LLMS_URL}: {type(exc).__name__}: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if len(body) > LLMS_MAX_BYTES:
|
||||||
|
print(f"Refusing {CANONICAL_LLMS_URL}: larger than {LLMS_MAX_BYTES} bytes")
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = body.decode("utf-8")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
print(f"Refusing {CANONICAL_LLMS_URL}: not valid UTF-8: {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# An index opens with a markdown heading and links to the docs site. A
|
||||||
|
# body that does not is an error page or a truncated response, not content
|
||||||
|
# worth publishing.
|
||||||
|
if not text.startswith("# ") or f"https://{ALLOWED_LLMS_HOST}/" not in text:
|
||||||
|
print(f"Refusing {CANONICAL_LLMS_URL}: does not look like an llms.txt index")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
def generate_redirects():
|
def generate_redirects():
|
||||||
script_dir = Path(__file__).parent
|
script_dir = Path(__file__).parent
|
||||||
output_dir = script_dir / "_site"
|
output_dir = script_dir / "_site"
|
||||||
@@ -126,14 +197,19 @@ def generate_redirects():
|
|||||||
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
|
catchall_404.write_text(CATCHALL_404_TEMPLATE.format(default_url=DEFAULT_REDIRECT))
|
||||||
print(f"Created: {catchall_404}")
|
print(f"Created: {catchall_404}")
|
||||||
|
|
||||||
# Copy static files (like llms.txt) that can't be redirected via HTML
|
# llms.txt can't be redirected via HTML, so publish the docs site's own
|
||||||
static_files = ["llms.txt"]
|
# generated index. The committed copy is only a fallback.
|
||||||
for static_file in static_files:
|
llms_txt = fetch_canonical_llms_txt()
|
||||||
src = script_dir / static_file
|
if llms_txt is not None:
|
||||||
|
(output_dir / "llms.txt").write_text(llms_txt)
|
||||||
|
print(f"Fetched: {output_dir / 'llms.txt'} (from {CANONICAL_LLMS_URL})")
|
||||||
|
else:
|
||||||
|
src = script_dir / "llms.txt"
|
||||||
if src.exists():
|
if src.exists():
|
||||||
dst = output_dir / static_file
|
(output_dir / "llms.txt").write_text(src.read_text())
|
||||||
dst.write_text(src.read_text())
|
print(f"Copied: {output_dir / 'llms.txt'} (fallback, may be stale)")
|
||||||
print(f"Copied: {dst}")
|
else:
|
||||||
|
print("No llms.txt fetched and no committed fallback; skipping")
|
||||||
|
|
||||||
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
|
print(f"\nGenerated {len(redirects)} redirect files in {output_dir}")
|
||||||
|
|
||||||
|
|||||||
+46
-32
@@ -1,35 +1,49 @@
|
|||||||
# LangGraph
|
# Docs by LangChain: LangGraph (Python)
|
||||||
|
|
||||||
LangGraph documentation has moved to docs.langchain.com.
|
> Markdown index of the LangGraph (Python) documentation.
|
||||||
|
|
||||||
## Overview
|
## LangGraph (Python)
|
||||||
|
|
||||||
- [LangGraph Overview](https://docs.langchain.com/oss/python/langgraph/overview): Introduction to LangGraph, a library for building stateful, multi-actor applications with LLMs.
|
- [Memory](https://docs.langchain.com/oss/python/langgraph/add-memory.md)
|
||||||
- [Why LangGraph?](https://docs.langchain.com/oss/python/langgraph/why-langgraph): Motivation for LangGraph and its key features.
|
- [Build a custom RAG agent with LangGraph](https://docs.langchain.com/oss/python/langgraph/agentic-rag.md)
|
||||||
|
- [Application structure](https://docs.langchain.com/oss/python/langgraph/application-structure.md)
|
||||||
## Core Concepts
|
- [Backward compatibility](https://docs.langchain.com/oss/python/langgraph/backward-compatibility.md)
|
||||||
|
- [Case studies](https://docs.langchain.com/oss/python/langgraph/case-studies.md)
|
||||||
- [Graph API](https://docs.langchain.com/oss/python/langgraph/graph-api): Learn how to define state, create nodes, and connect them with edges.
|
- [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-js.md)
|
||||||
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming): Stream outputs from your graph for better UX.
|
- [Changelog](https://docs.langchain.com/oss/python/langgraph/changelog-py.md)
|
||||||
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence): Add memory and checkpointing to your graphs.
|
- [Checkpointers](https://docs.langchain.com/oss/python/langgraph/checkpointers.md)
|
||||||
- [Add Memory](https://docs.langchain.com/oss/python/langgraph/add-memory): Implement short-term and long-term memory.
|
- [Choosing between Graph and Functional APIs](https://docs.langchain.com/oss/python/langgraph/choosing-apis.md)
|
||||||
- [Workflows & Agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents): Build agents and workflows with LangGraph.
|
- [Deployment](https://docs.langchain.com/oss/python/langgraph/deploy.md)
|
||||||
|
- [GRAPH_RECURSION_LIMIT](https://docs.langchain.com/oss/python/langgraph/errors/GRAPH_RECURSION_LIMIT.md)
|
||||||
## How-To Guides
|
- [INVALID_CHAT_HISTORY](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CHAT_HISTORY.md)
|
||||||
|
- [INVALID_CONCURRENT_GRAPH_UPDATE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_CONCURRENT_GRAPH_UPDATE.md)
|
||||||
- [Use Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs): Compose graphs using subgraphs.
|
- [INVALID_GRAPH_NODE_RETURN_VALUE](https://docs.langchain.com/oss/python/langgraph/errors/INVALID_GRAPH_NODE_RETURN_VALUE.md)
|
||||||
- [Observability](https://docs.langchain.com/oss/python/langgraph/observability): Add tracing and debugging to your graphs.
|
- [MISSING_CHECKPOINTER](https://docs.langchain.com/oss/python/langgraph/errors/MISSING_CHECKPOINTER.md)
|
||||||
- [Common Errors](https://docs.langchain.com/oss/python/langgraph/common-errors): Troubleshoot common LangGraph errors.
|
- [MULTIPLE_SUBGRAPHS](https://docs.langchain.com/oss/python/langgraph/errors/MULTIPLE_SUBGRAPHS.md)
|
||||||
|
- [Event streaming](https://docs.langchain.com/oss/python/langgraph/event-streaming.md)
|
||||||
## Tutorials
|
- [Fault tolerance](https://docs.langchain.com/oss/python/langgraph/fault-tolerance.md)
|
||||||
|
- [Custom stream channels](https://docs.langchain.com/oss/python/langgraph/frontend/custom-stream-channels.md)
|
||||||
- [Agentic RAG](https://docs.langchain.com/oss/python/langgraph/agentic-rag): Build an agentic RAG system with LangGraph.
|
- [Graph execution](https://docs.langchain.com/oss/python/langgraph/frontend/graph-execution.md)
|
||||||
- [SQL Agent](https://docs.langchain.com/oss/python/langgraph/sql-agent): Create a SQL agent with LangGraph.
|
- [Overview](https://docs.langchain.com/oss/python/langgraph/frontend/overview.md)
|
||||||
|
- [Functional API overview](https://docs.langchain.com/oss/python/langgraph/functional-api.md)
|
||||||
## Reference
|
- [Graph API overview](https://docs.langchain.com/oss/python/langgraph/graph-api.md)
|
||||||
|
- [Install LangGraph](https://docs.langchain.com/oss/python/langgraph/install.md)
|
||||||
- [API Reference](https://reference.langchain.com/python/langgraph/): Complete API documentation for LangGraph.
|
- [Interrupts](https://docs.langchain.com/oss/python/langgraph/interrupts.md)
|
||||||
|
- [Run a local server](https://docs.langchain.com/oss/python/langgraph/local-server.md)
|
||||||
## LangGraph Platform
|
- [LangSmith Observability](https://docs.langchain.com/oss/python/langgraph/observability.md)
|
||||||
|
- [LangGraph overview](https://docs.langchain.com/oss/python/langgraph/overview.md)
|
||||||
For deploying LangGraph applications in production, see the [LangSmith documentation](https://docs.langchain.com/langsmith/agent-server).
|
- [Persistence](https://docs.langchain.com/oss/python/langgraph/persistence.md)
|
||||||
|
- [LangGraph runtime](https://docs.langchain.com/oss/python/langgraph/pregel.md)
|
||||||
|
- [Quickstart](https://docs.langchain.com/oss/python/langgraph/quickstart.md)
|
||||||
|
- [Build a custom SQL agent](https://docs.langchain.com/oss/python/langgraph/sql-agent.md)
|
||||||
|
- [Stores](https://docs.langchain.com/oss/python/langgraph/stores.md)
|
||||||
|
- [Streaming](https://docs.langchain.com/oss/python/langgraph/streaming.md)
|
||||||
|
- [LangSmith Studio](https://docs.langchain.com/oss/python/langgraph/studio.md)
|
||||||
|
- [Test](https://docs.langchain.com/oss/python/langgraph/test.md)
|
||||||
|
- [Thinking in LangGraph](https://docs.langchain.com/oss/python/langgraph/thinking-in-langgraph.md)
|
||||||
|
- [Agent Chat UI](https://docs.langchain.com/oss/python/langgraph/ui.md)
|
||||||
|
- [Use the functional API](https://docs.langchain.com/oss/python/langgraph/use-functional-api.md)
|
||||||
|
- [Use the graph API](https://docs.langchain.com/oss/python/langgraph/use-graph-api.md)
|
||||||
|
- [Subgraphs](https://docs.langchain.com/oss/python/langgraph/use-subgraphs.md)
|
||||||
|
- [Use time-travel](https://docs.langchain.com/oss/python/langgraph/use-time-travel.md)
|
||||||
|
- [Workflows and agents](https://docs.langchain.com/oss/python/langgraph/workflows-agents.md)
|
||||||
|
|||||||
Generated
+6
-6
@@ -13,16 +13,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.13.0"
|
version = "4.14.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -943,11 +943,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -448,11 +448,12 @@ class PostgresSaver(BasePostgresSaver):
|
|||||||
|
|
||||||
Two-stage query, both stages cover ALL requested channels:
|
Two-stage query, both stages cover ALL requested channels:
|
||||||
|
|
||||||
* Stage 1 (paged): dynamic SELECT over `checkpoints` with K parallel
|
* Stage 1 (paged): dynamic SELECT over `checkpoints` with three
|
||||||
JSONB key lookups (one column pair per channel) — no subquery, no
|
columns per channel: its version, an `EXISTS` probe for a stored
|
||||||
aggregation. Pages newest-first by `checkpoint_id` with a cursor;
|
blob at that version, and its inline value. Pages newest-first by
|
||||||
page size is `_DELTA_PAGE_SIZE`. Stops paging when every channel
|
`checkpoint_id` with a cursor; page size is `_DELTA_PAGE_SIZE`.
|
||||||
has found its seed or the chain is exhausted.
|
Stops paging when every channel has found its seed or a page comes
|
||||||
|
back short.
|
||||||
|
|
||||||
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
||||||
`checkpoint_writes` filtered to that channel's specific
|
`checkpoint_writes` filtered to that channel's specific
|
||||||
|
|||||||
@@ -172,30 +172,8 @@ class _DeltaStage2Row(TypedDict, total=False):
|
|||||||
version: str | None # "b" rows only
|
version: str | None # "b" rows only
|
||||||
|
|
||||||
|
|
||||||
# Multi-channel two-stage DeltaChannel reconstruction.
|
# Delta history is rebuilt in two queries; `_build_delta_stage1_sql` and
|
||||||
#
|
# `_build_delta_stage2_sql` document their shapes.
|
||||||
# Stage 1 scans checkpoint metadata (no blob bytes) and emits one row per
|
|
||||||
# checkpoint with K parallel JSONB key lookups (one column pair per
|
|
||||||
# requested delta channel: ver_i / hs_i). No subqueries, no aggregation.
|
|
||||||
# Python walks the parent chain once across all channels.
|
|
||||||
#
|
|
||||||
# Stage 2 fetches all writes and the seed blobs for ALL channels in a
|
|
||||||
# single roundtrip via `channel = ANY(%s)` and chain/seed-version
|
|
||||||
# filtering.
|
|
||||||
#
|
|
||||||
# Empirical comparison vs an alternative "ship full channel_versions /
|
|
||||||
# channel_values JSONB and let Python pick" form (1000 checkpoints,
|
|
||||||
# 8 total channels in graph, 3 delta channels requested):
|
|
||||||
#
|
|
||||||
# Postgres execution: A=0.24ms vs B=0.38ms (both negligible)
|
|
||||||
# End-to-end latency: A=6.83ms vs B=2.28ms (B is 3.0x faster)
|
|
||||||
# Wire payload: A=836KB vs B=330KB (61% smaller)
|
|
||||||
# Buffer hits: identical (167 blocks)
|
|
||||||
#
|
|
||||||
# B (this dynamic-columns design) wins because it avoids JSONB
|
|
||||||
# serialization on the wire and JSONB-to-dict deserialization in
|
|
||||||
# psycopg. Even at K=8 (8 delta channels = 16 dynamic columns), B
|
|
||||||
# still beats A end-to-end (4.2ms vs 6.8ms).
|
|
||||||
|
|
||||||
|
|
||||||
def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
|
def _build_delta_stage1_sql(channels: Sequence[str], *, paged: bool) -> str:
|
||||||
@@ -335,10 +313,8 @@ def _build_delta_stage2_sql(
|
|||||||
return " UNION ALL ".join(branches)
|
return " UNION ALL ".join(branches)
|
||||||
|
|
||||||
|
|
||||||
# Stage 1 rows are dynamic-shape dicts: {checkpoint_id, parent_checkpoint_id,
|
# Stage 1 rows are dicts keyed by the per-channel aliases
|
||||||
# ver_0, hs_0, ver_1, hs_1, ...}. Walking is parameterized by the channel
|
# `_build_delta_stage1_sql` emits, so there is no static TypedDict.
|
||||||
# list to map indices back to channel names — no static TypedDict here.
|
|
||||||
# `dict[str, Any]` is the practical signature.
|
|
||||||
|
|
||||||
|
|
||||||
class BasePostgresSaver(BaseCheckpointSaver[str]):
|
class BasePostgresSaver(BaseCheckpointSaver[str]):
|
||||||
@@ -431,9 +407,11 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|||||||
(a) it found a stored value for its channel — a blob or an inline
|
(a) it found a stored value for its channel — a blob or an inline
|
||||||
primitive (channel becomes seeded),
|
primitive (channel becomes seeded),
|
||||||
(b) it reached a real root (parent_of[cid] is None — fully
|
(b) it reached a real root (parent_of[cid] is None — fully
|
||||||
materialized at this point), or
|
materialized at this point),
|
||||||
(c) the next ancestor cid isn't in `parent_of` yet (waiting for
|
(c) the next ancestor cid isn't in `parent_of` yet (waiting for
|
||||||
a later page; the cursor stays put).
|
a later page; the cursor stays put), or
|
||||||
|
(d) the target's own row isn't in `parent_of` yet (the walk has
|
||||||
|
not started; no cursor is set, so a later page retries).
|
||||||
|
|
||||||
Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`,
|
Mutates `chain_by_ch`, `seed_ver_by_ch`, `seed_inline_by_ch`,
|
||||||
`walk_cursor_by_ch`, and `seeded` in place.
|
`walk_cursor_by_ch`, and `seeded` in place.
|
||||||
@@ -441,9 +419,12 @@ class BasePostgresSaver(BaseCheckpointSaver[str]):
|
|||||||
for i, ch in enumerate(channels):
|
for i, ch in enumerate(channels):
|
||||||
if ch in seeded:
|
if ch in seeded:
|
||||||
continue
|
continue
|
||||||
# First-time entry: cursor starts at the target's parent.
|
# Pages start at the thread head, so the target may not have
|
||||||
|
# loaded yet; a `None` cursor would read as "target is a root".
|
||||||
if ch not in walk_cursor_by_ch:
|
if ch not in walk_cursor_by_ch:
|
||||||
walk_cursor_by_ch[ch] = parent_of.get(target_id)
|
if target_id not in parent_of:
|
||||||
|
continue
|
||||||
|
walk_cursor_by_ch[ch] = parent_of[target_id]
|
||||||
cur_cid = walk_cursor_by_ch[ch]
|
cur_cid = walk_cursor_by_ch[ch]
|
||||||
ch_chain = chain_by_ch[ch]
|
ch_chain = chain_by_ch[ch]
|
||||||
hb_i = hb_by_i_by_cid[i]
|
hb_i = hb_by_i_by_cid[i]
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langgraph.checkpoint.base import (
|
||||||
|
Checkpoint,
|
||||||
|
DeltaChannelHistory,
|
||||||
|
empty_checkpoint,
|
||||||
|
)
|
||||||
|
from langgraph.checkpoint.base.id import uuid6
|
||||||
|
from langgraph.checkpoint.serde.types import _DeltaSnapshot
|
||||||
|
|
||||||
|
from langgraph.checkpoint.postgres import PostgresSaver
|
||||||
|
from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver
|
||||||
|
from langgraph.checkpoint.postgres.base import _DELTA_PAGE_SIZE
|
||||||
|
from tests.conftest import DEFAULT_URI
|
||||||
|
|
||||||
|
CHANNEL = "items"
|
||||||
|
STEPS = 8
|
||||||
|
SEED_STEP = 1
|
||||||
|
SEED_VALUE = [10, 20]
|
||||||
|
TARGET_STEP = 4
|
||||||
|
|
||||||
|
# The real page size is the control; the rest leave the target off the first
|
||||||
|
# page (three checkpoints are newer than it).
|
||||||
|
PAGE_SIZES = [_DELTA_PAGE_SIZE, 3, 2, 1]
|
||||||
|
|
||||||
|
|
||||||
|
def _step_args(
|
||||||
|
thread_id: str, step: int, parent: dict | None
|
||||||
|
) -> tuple[dict, Checkpoint, dict[str, Any]]:
|
||||||
|
config: dict = {"configurable": {"thread_id": thread_id, "checkpoint_ns": ""}}
|
||||||
|
if parent is not None:
|
||||||
|
config["configurable"]["checkpoint_id"] = parent["configurable"][
|
||||||
|
"checkpoint_id"
|
||||||
|
]
|
||||||
|
checkpoint: Checkpoint = empty_checkpoint()
|
||||||
|
checkpoint["id"] = str(uuid6(clock_seq=step))
|
||||||
|
checkpoint["channel_versions"][CHANNEL] = f"v{step}"
|
||||||
|
if step == SEED_STEP:
|
||||||
|
checkpoint["channel_values"][CHANNEL] = _DeltaSnapshot(list(SEED_VALUE))
|
||||||
|
return config, checkpoint, {CHANNEL: f"v{step}"}
|
||||||
|
return config, checkpoint, {}
|
||||||
|
|
||||||
|
|
||||||
|
async def _abuild_chain(saver: AsyncPostgresSaver) -> list[dict]:
|
||||||
|
thread_id = str(uuid4())
|
||||||
|
parent: dict | None = None
|
||||||
|
configs: list[dict] = []
|
||||||
|
for step in range(STEPS):
|
||||||
|
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
|
||||||
|
parent = await saver.aput(
|
||||||
|
config,
|
||||||
|
checkpoint,
|
||||||
|
{"source": "loop", "step": step, "parents": {}},
|
||||||
|
new_versions,
|
||||||
|
)
|
||||||
|
await saver.aput_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
||||||
|
configs.append(parent)
|
||||||
|
return configs
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chain(saver: PostgresSaver) -> list[dict]:
|
||||||
|
thread_id = str(uuid4())
|
||||||
|
parent: dict | None = None
|
||||||
|
configs: list[dict] = []
|
||||||
|
for step in range(STEPS):
|
||||||
|
config, checkpoint, new_versions = _step_args(thread_id, step, parent)
|
||||||
|
parent = saver.put(
|
||||||
|
config,
|
||||||
|
checkpoint,
|
||||||
|
{"source": "loop", "step": step, "parents": {}},
|
||||||
|
new_versions,
|
||||||
|
)
|
||||||
|
saver.put_writes(parent, [(CHANNEL, f"w{step}")], str(uuid4()))
|
||||||
|
configs.append(parent)
|
||||||
|
return configs
|
||||||
|
|
||||||
|
|
||||||
|
def _assert_history(entry: DeltaChannelHistory, page_size: int) -> None:
|
||||||
|
seed = entry.get("seed")
|
||||||
|
assert isinstance(seed, _DeltaSnapshot), (
|
||||||
|
f"page_size={page_size}: expected a snapshot seed, "
|
||||||
|
f"got {entry.get('seed', '<missing>')!r}"
|
||||||
|
)
|
||||||
|
assert seed.value == SEED_VALUE
|
||||||
|
assert [w[2] for w in entry["writes"]] == ["w1", "w2", "w3"], (
|
||||||
|
f"page_size={page_size}: got {[w[2] for w in entry['writes']]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("page_size", PAGE_SIZES)
|
||||||
|
async def test_async_target_older_than_the_first_page(
|
||||||
|
page_size: int, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", page_size)
|
||||||
|
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||||
|
await saver.setup()
|
||||||
|
configs = await _abuild_chain(saver)
|
||||||
|
result = await saver.aget_delta_channel_history(
|
||||||
|
config=configs[TARGET_STEP], channels=[CHANNEL]
|
||||||
|
)
|
||||||
|
_assert_history(result[CHANNEL], page_size)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("page_size", PAGE_SIZES)
|
||||||
|
def test_sync_target_older_than_the_first_page(
|
||||||
|
page_size: int, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("langgraph.checkpoint.postgres._DELTA_PAGE_SIZE", page_size)
|
||||||
|
with PostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||||
|
saver.setup()
|
||||||
|
configs = _build_chain(saver)
|
||||||
|
result = saver.get_delta_channel_history(
|
||||||
|
config=configs[TARGET_STEP], channels=[CHANNEL]
|
||||||
|
)
|
||||||
|
_assert_history(result[CHANNEL], page_size)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_root_target_has_no_history_and_still_terminates(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr("langgraph.checkpoint.postgres.aio._DELTA_PAGE_SIZE", 1)
|
||||||
|
async with AsyncPostgresSaver.from_conn_string(DEFAULT_URI) as saver:
|
||||||
|
await saver.setup()
|
||||||
|
configs = await _abuild_chain(saver)
|
||||||
|
result = await saver.aget_delta_channel_history(
|
||||||
|
config=configs[0], channels=[CHANNEL]
|
||||||
|
)
|
||||||
|
assert result[CHANNEL] == {"writes": []}
|
||||||
Generated
+3
-3
@@ -1122,11 +1122,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -507,13 +507,12 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|||||||
|
|
||||||
Two-stage query:
|
Two-stage query:
|
||||||
|
|
||||||
* Stage 1 (paged): newest-first slice of `checkpoints` returning
|
* Stage 1 (streamed): recursive CTE over `checkpoints` following
|
||||||
`(checkpoint_id, parent_checkpoint_id, type, checkpoint)` per
|
`parent_checkpoint_id` from the target, returning
|
||||||
ancestor. Sqlite has no JSONB, so we ship the full serialized
|
`(checkpoint_id, type, checkpoint)` per ancestor. Sqlite has no
|
||||||
checkpoint blob and inspect `channel_values` in Python. Pages
|
JSONB, so we ship the full serialized checkpoint blob and inspect
|
||||||
newest-first by `checkpoint_id` with a `< cursor` predicate;
|
`channel_values` in Python. Stops reading when every channel has
|
||||||
page size is `DELTA_PAGE_SIZE`. Stops paging when every channel
|
found its seed or the chain is exhausted.
|
||||||
has found its seed or the chain is exhausted.
|
|
||||||
|
|
||||||
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
* Stage 2 (per-channel UNION ALL): one branch per channel reading
|
||||||
`writes` filtered to that channel's specific `chain_cids`. No
|
`writes` filtered to that channel's specific `chain_cids`. No
|
||||||
@@ -538,12 +537,14 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
|||||||
seeded: set[str] = set()
|
seeded: set[str] = set()
|
||||||
|
|
||||||
with self.cursor(transaction=False) as cur:
|
with self.cursor(transaction=False) as cur:
|
||||||
cur.execute(DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id))
|
cur.execute(
|
||||||
|
DELTA_STAGE1_SQL,
|
||||||
|
(thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
|
||||||
|
)
|
||||||
for row in cur:
|
for row in cur:
|
||||||
cid, parent_cid, type_tag, blob = row
|
cid, type_tag, blob = row
|
||||||
if step_walk_with_row(
|
if step_walk_with_row(
|
||||||
cid=cid,
|
cid=cid,
|
||||||
parent_cid=parent_cid,
|
|
||||||
type_tag=type_tag,
|
type_tag=type_tag,
|
||||||
blob=blob,
|
blob=blob,
|
||||||
target_id=checkpoint_id,
|
target_id=checkpoint_id,
|
||||||
|
|||||||
@@ -26,16 +26,33 @@ from typing import Any
|
|||||||
|
|
||||||
from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite
|
from langgraph.checkpoint.base import DeltaChannelHistory, PendingWrite
|
||||||
|
|
||||||
# Stage 1 streams ancestors of `target_cid` newest-first. The `<=`
|
# Stage 1 streams target, then its ancestors nearest-first, by following
|
||||||
# predicate keeps target itself in the stream so we can read its
|
# `parent_checkpoint_id` rather than id order: ids are only monotonic within
|
||||||
# `parent_checkpoint_id` from the first row without a separate lookup;
|
# one process, so a range scan by id can miss a parent whose id sorts above
|
||||||
# the caller skips target's own writes/seed (matches the
|
# its child's. Target is the anchor row; its own writes/seed are skipped
|
||||||
# `BaseCheckpointSaver` contract).
|
# (matches the `BaseCheckpointSaver` contract).
|
||||||
|
#
|
||||||
|
# `put` is `INSERT OR REPLACE`, so re-putting an existing id under a
|
||||||
|
# descendant's config makes the chain a loop. `step_walk_with_row` stops on a
|
||||||
|
# repeated id; sqlite yields recursive rows lazily, so abandoning the cursor
|
||||||
|
# ends the recursion.
|
||||||
|
#
|
||||||
|
# `CROSS JOIN` pins `ancestors` as the outer loop, so each step is one primary
|
||||||
|
# key lookup. With a plain `JOIN` and no `ANALYZE` stats, sqlite can put
|
||||||
|
# `checkpoints` outside and scan the whole thread per step.
|
||||||
DELTA_STAGE1_SQL = (
|
DELTA_STAGE1_SQL = (
|
||||||
|
"WITH RECURSIVE ancestors(checkpoint_id, parent_checkpoint_id, type, "
|
||||||
|
"checkpoint) AS ("
|
||||||
"SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
|
"SELECT checkpoint_id, parent_checkpoint_id, type, checkpoint "
|
||||||
"FROM checkpoints "
|
"FROM checkpoints "
|
||||||
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id <= ? "
|
"WHERE thread_id = ? AND checkpoint_ns = ? AND checkpoint_id = ? "
|
||||||
"ORDER BY checkpoint_id DESC"
|
"UNION ALL "
|
||||||
|
"SELECT c.checkpoint_id, c.parent_checkpoint_id, c.type, c.checkpoint "
|
||||||
|
"FROM ancestors a CROSS JOIN checkpoints c "
|
||||||
|
"ON c.checkpoint_id = a.parent_checkpoint_id "
|
||||||
|
"WHERE c.thread_id = ? AND c.checkpoint_ns = ?"
|
||||||
|
") "
|
||||||
|
"SELECT checkpoint_id, type, checkpoint FROM ancestors"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -68,7 +85,6 @@ def build_delta_stage2_sql(*, chain_lens: Sequence[int]) -> str:
|
|||||||
def step_walk_with_row(
|
def step_walk_with_row(
|
||||||
*,
|
*,
|
||||||
cid: str,
|
cid: str,
|
||||||
parent_cid: str | None,
|
|
||||||
type_tag: str,
|
type_tag: str,
|
||||||
blob: bytes,
|
blob: bytes,
|
||||||
target_id: str,
|
target_id: str,
|
||||||
@@ -81,36 +97,32 @@ def step_walk_with_row(
|
|||||||
) -> bool:
|
) -> bool:
|
||||||
"""Process one streamed stage-1 row in the merged ancestor walk.
|
"""Process one streamed stage-1 row in the merged ancestor walk.
|
||||||
|
|
||||||
The cursor returns (cid, parent_cid, type, blob) rows in
|
The cursor returns (cid, type, blob) rows in walk order starting at
|
||||||
`checkpoint_id` DESC order starting at target. The first row is
|
target. The first row is target itself and is skipped (target's own
|
||||||
target itself; we read its parent_cid to seed the walk and otherwise
|
writes/seed are not part of the contract).
|
||||||
skip it (target's own writes/seed are not part of the contract).
|
|
||||||
|
|
||||||
For each subsequent row, if `cid` matches the walk's current
|
For each subsequent row we deserialize the blob, append the cid to
|
||||||
position, we deserialize the blob, append the cid to every
|
every not-yet-seeded channel's chain, and check `channel_values` for
|
||||||
not-yet-seeded channel's chain, and check `channel_values` for
|
|
||||||
seeds. The deserialized checkpoint is dropped before advancing — no
|
seeds. The deserialized checkpoint is dropped before advancing — no
|
||||||
cross-row cache, so peak in-flight is one deserialized checkpoint.
|
cross-row cache, so peak in-flight is one deserialized checkpoint.
|
||||||
|
|
||||||
Off-path rows (different branch on the same thread) advance the
|
Returns True when the caller can stop iterating and close the cursor:
|
||||||
cursor without doing any work.
|
every requested channel is seeded, or the chain revisited a checkpoint.
|
||||||
|
|
||||||
Returns True when every requested channel is seeded — the caller
|
|
||||||
can stop iterating and close the cursor.
|
|
||||||
"""
|
"""
|
||||||
if "started" not in walk_state:
|
if "started" not in walk_state:
|
||||||
if cid == target_id:
|
if cid == target_id:
|
||||||
walk_state["started"] = True
|
walk_state["started"] = True
|
||||||
walk_state["cur_cid"] = parent_cid
|
|
||||||
walk_state["active"] = {ch for ch in channels if ch not in seeded}
|
walk_state["active"] = {ch for ch in channels if ch not in seeded}
|
||||||
|
walk_state["walked"] = {cid}
|
||||||
# Not target yet (or target not present): keep streaming.
|
# Not target yet (or target not present): keep streaming.
|
||||||
return False
|
return False
|
||||||
active: set[str] = walk_state["active"]
|
active: set[str] = walk_state["active"]
|
||||||
if not active:
|
if not active:
|
||||||
return True
|
return True
|
||||||
if cid != walk_state["cur_cid"]:
|
walked: set[str] = walk_state["walked"]
|
||||||
# Off-path row from a sibling branch — skip without deserializing.
|
if cid in walked:
|
||||||
return False
|
return True
|
||||||
|
walked.add(cid)
|
||||||
for ch in active:
|
for ch in active:
|
||||||
chain_by_ch[ch].append(cid)
|
chain_by_ch[ch].append(cid)
|
||||||
ckpt = serde.loads_typed((type_tag, blob))
|
ckpt = serde.loads_typed((type_tag, blob))
|
||||||
@@ -120,7 +132,6 @@ def step_walk_with_row(
|
|||||||
seeded.add(ch)
|
seeded.add(ch)
|
||||||
active.discard(ch)
|
active.discard(ch)
|
||||||
del ckpt, channel_values
|
del ckpt, channel_values
|
||||||
walk_state["cur_cid"] = parent_cid
|
|
||||||
return not active
|
return not active
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -625,8 +625,8 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
|||||||
"""Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
|
"""Fast-path override of `BaseCheckpointSaver.aget_delta_channel_history`.
|
||||||
|
|
||||||
See `SqliteSaver.get_delta_channel_history` for design notes; this
|
See `SqliteSaver.get_delta_channel_history` for design notes; this
|
||||||
is the async equivalent using `aiosqlite` cursors. Stage 1 pages
|
is the async equivalent using `aiosqlite` cursors. Stage 1 streams
|
||||||
the parent chain newest-first and Python-deserializes each
|
the parent chain from the target and Python-deserializes each
|
||||||
checkpoint blob to find per-channel snapshots; stage 2 fetches
|
checkpoint blob to find per-channel snapshots; stage 2 fetches
|
||||||
only the relevant writes via per-channel UNION ALL.
|
only the relevant writes via per-channel UNION ALL.
|
||||||
"""
|
"""
|
||||||
@@ -650,13 +650,13 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
|||||||
|
|
||||||
async with self.lock, self.conn.cursor() as cur:
|
async with self.lock, self.conn.cursor() as cur:
|
||||||
await cur.execute(
|
await cur.execute(
|
||||||
DELTA_STAGE1_SQL, (thread_id, checkpoint_ns, checkpoint_id)
|
DELTA_STAGE1_SQL,
|
||||||
|
(thread_id, checkpoint_ns, checkpoint_id, thread_id, checkpoint_ns),
|
||||||
)
|
)
|
||||||
async for row in cur:
|
async for row in cur:
|
||||||
cid, parent_cid, type_tag, blob = row
|
cid, type_tag, blob = row
|
||||||
if step_walk_with_row(
|
if step_walk_with_row(
|
||||||
cid=cid,
|
cid=cid,
|
||||||
parent_cid=parent_cid,
|
|
||||||
type_tag=type_tag,
|
type_tag=type_tag,
|
||||||
blob=blob,
|
blob=blob,
|
||||||
target_id=checkpoint_id,
|
target_id=checkpoint_id,
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from langgraph.checkpoint.base import (
|
||||||
|
BaseCheckpointSaver,
|
||||||
|
Checkpoint,
|
||||||
|
DeltaChannelHistory,
|
||||||
|
empty_checkpoint,
|
||||||
|
)
|
||||||
|
|
||||||
|
from langgraph.checkpoint.sqlite import SqliteSaver
|
||||||
|
from langgraph.checkpoint.sqlite._delta import DELTA_STAGE1_SQL
|
||||||
|
from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver
|
||||||
|
|
||||||
|
CHANNEL = "ch"
|
||||||
|
CONFIG: dict[str, Any] = {"configurable": {"thread_id": "t", "checkpoint_ns": ""}}
|
||||||
|
EXPECTED: DeltaChannelHistory = {
|
||||||
|
"writes": [("task", CHANNEL, "write-root")],
|
||||||
|
"seed": "seed",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _checkpoint(checkpoint_id: str, values: dict[str, Any]) -> Checkpoint:
|
||||||
|
value = empty_checkpoint()
|
||||||
|
value["id"] = checkpoint_id
|
||||||
|
value["channel_values"] = values
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
PARENT_ID_ORDERS = [
|
||||||
|
pytest.param("z-older", "a-newer", id="parent_id_sorts_above_child"),
|
||||||
|
pytest.param("a-older", "z-newer", id="parent_id_sorts_below_child"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
|
||||||
|
def test_sync_walk_reaches_parent_whatever_the_id_order(
|
||||||
|
root_id: str, child_id: str
|
||||||
|
) -> None:
|
||||||
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||||
|
root = saver.put(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
|
||||||
|
saver.put_writes(root, [(CHANNEL, "write-root")], "task")
|
||||||
|
child = saver.put(root, _checkpoint(child_id, {}), {}, {})
|
||||||
|
|
||||||
|
got = saver.get_delta_channel_history(config=child, channels=[CHANNEL])
|
||||||
|
reference = BaseCheckpointSaver.get_delta_channel_history(
|
||||||
|
saver, config=child, channels=[CHANNEL]
|
||||||
|
)
|
||||||
|
assert got[CHANNEL] == EXPECTED
|
||||||
|
assert got[CHANNEL] == reference[CHANNEL], "fast path disagrees with base"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("root_id", "child_id"), PARENT_ID_ORDERS)
|
||||||
|
async def test_async_walk_reaches_parent_whatever_the_id_order(
|
||||||
|
root_id: str, child_id: str
|
||||||
|
) -> None:
|
||||||
|
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||||
|
root = await saver.aput(CONFIG, _checkpoint(root_id, {CHANNEL: "seed"}), {}, {})
|
||||||
|
await saver.aput_writes(root, [(CHANNEL, "write-root")], "task")
|
||||||
|
child = await saver.aput(root, _checkpoint(child_id, {}), {}, {})
|
||||||
|
|
||||||
|
got = await saver.aget_delta_channel_history(config=child, channels=[CHANNEL])
|
||||||
|
assert got[CHANNEL] == EXPECTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_walk_reaches_root_of_long_chain_with_descending_ids() -> None:
|
||||||
|
steps = 40
|
||||||
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||||
|
parent = saver.put(
|
||||||
|
CONFIG, _checkpoint(f"id-{steps:03d}", {CHANNEL: "seed"}), {}, {}
|
||||||
|
)
|
||||||
|
saver.put_writes(parent, [(CHANNEL, "write-root")], "task")
|
||||||
|
for step in range(steps - 1, 0, -1):
|
||||||
|
parent = saver.put(parent, _checkpoint(f"id-{step:03d}", {}), {}, {})
|
||||||
|
|
||||||
|
got = saver.get_delta_channel_history(config=parent, channels=[CHANNEL])
|
||||||
|
assert got[CHANNEL] == EXPECTED
|
||||||
|
|
||||||
|
|
||||||
|
def test_walk_terminates_when_put_makes_the_parent_chain_cycle() -> None:
|
||||||
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||||
|
a = saver.put(CONFIG, _checkpoint("cid-a", {}), {}, {})
|
||||||
|
b = saver.put(a, _checkpoint("cid-b", {}), {}, {})
|
||||||
|
repoint_a_under_b = _checkpoint("cid-a", {})
|
||||||
|
saver.put(b, repoint_a_under_b, {}, {})
|
||||||
|
|
||||||
|
got = saver.get_delta_channel_history(config=b, channels=[CHANNEL])
|
||||||
|
assert got[CHANNEL] == {"writes": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_walk_step_looks_up_the_parent_by_primary_key() -> None:
|
||||||
|
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||||
|
saver.setup()
|
||||||
|
plan = [
|
||||||
|
row[3]
|
||||||
|
for row in saver.conn.execute(
|
||||||
|
f"EXPLAIN QUERY PLAN {DELTA_STAGE1_SQL}", ("t", "", "id", "t", "")
|
||||||
|
)
|
||||||
|
]
|
||||||
|
assert any(
|
||||||
|
step.startswith("SEARCH c ") and "checkpoint_id=?" in step for step in plan
|
||||||
|
), f"recursive step should look up the parent by key, got {plan}"
|
||||||
Generated
+6
-6
@@ -26,16 +26,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.12.1"
|
version = "4.14.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1057,11 +1057,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Generated
+6
-6
@@ -19,16 +19,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.12.1"
|
version = "4.14.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1338,11 +1338,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ The CLI uses a `langgraph.json` configuration file with these key settings:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Git dependencies should use credential-free URLs. The CLI conservatively scans direct `langgraph.json` dependencies, common Python package files, uv project and lock files, and common Node.js package and lock files for HTTP Git URLs with userinfo. This check is not exhaustive: generated Docker builds can copy other files, including nested requirement or constraint files, into image layers without scanning them. For private dependencies, provide short-lived credentials through your build environment's secret-backed Git credential helper. Do not store credentials in copied files such as `langgraph.json` or `pip_config_file`.
|
||||||
|
|
||||||
See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options.
|
See the [full documentation](https://reference.langchain.com/python/langgraph-cli) for detailed configuration options.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|||||||
@@ -5,5 +5,5 @@ description = "Test for prerelease stuff"
|
|||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"langgraph==1.1.5"
|
"langgraph==1.2.6"
|
||||||
]
|
]
|
||||||
@@ -7,7 +7,7 @@ requires-python = ">=3.10"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"langchain-openai==1.1.14",
|
"langchain-openai==1.1.14",
|
||||||
"langchain-anthropic==1.4.6",
|
"langchain-anthropic==1.4.6",
|
||||||
"langgraph==1.1.5"
|
"langgraph==1.2.6"
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
|
|||||||
Generated
-285
@@ -1,285 +0,0 @@
|
|||||||
# This file is automatically @generated by Poetry 2.0.0 and should not be changed by hand.
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "anyio"
|
|
||||||
version = "4.4.0"
|
|
||||||
description = "High level compatibility layer for multiple asynchronous event loop implementations"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "anyio-4.4.0-py3-none-any.whl", hash = "sha256:c1b2d8f46a8a812513012e1107cb0e68c17159a7a594208005a57dc776e1bdc7"},
|
|
||||||
{file = "anyio-4.4.0.tar.gz", hash = "sha256:5aadc6a1bbb7cdb0bede386cac5e2940f5e2ff3aa20277e991cf028e0585ce94"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
exceptiongroup = {version = ">=1.0.2", markers = "python_version < \"3.11\""}
|
|
||||||
idna = ">=2.8"
|
|
||||||
sniffio = ">=1.1"
|
|
||||||
typing-extensions = {version = ">=4.1", markers = "python_version < \"3.11\""}
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
doc = ["Sphinx (>=7)", "packaging", "sphinx-autodoc-typehints (>=1.2.0)", "sphinx-rtd-theme"]
|
|
||||||
test = ["anyio[trio]", "coverage[toml] (>=7)", "exceptiongroup (>=1.2.0)", "hypothesis (>=4.0)", "psutil (>=5.9)", "pytest (>=7.0)", "pytest-mock (>=3.6.1)", "trustme", "uvloop (>=0.17)"]
|
|
||||||
trio = ["trio (>=0.23)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "certifi"
|
|
||||||
version = "2024.7.4"
|
|
||||||
description = "Python package for providing Mozilla's CA Bundle."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.6"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "certifi-2024.7.4-py3-none-any.whl", hash = "sha256:c198e21b1289c2ab85ee4e67bb4b4ef3ead0892059901a8d5b622f24a1101e90"},
|
|
||||||
{file = "certifi-2024.7.4.tar.gz", hash = "sha256:5a1e7645bc0ec61a09e26c36f6106dd4cf40c6db3a1fb6352b0244e7fb057c7b"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "click"
|
|
||||||
version = "8.1.7"
|
|
||||||
description = "Composable command line interface toolkit"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
|
|
||||||
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
colorama = {version = "*", markers = "platform_system == \"Windows\""}
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "colorama"
|
|
||||||
version = "0.4.6"
|
|
||||||
description = "Cross-platform colored terminal text."
|
|
||||||
optional = false
|
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
|
||||||
groups = ["main"]
|
|
||||||
markers = "platform_system == \"Windows\""
|
|
||||||
files = [
|
|
||||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
|
||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "exceptiongroup"
|
|
||||||
version = "1.2.1"
|
|
||||||
description = "Backport of PEP 654 (exception groups)"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
groups = ["main"]
|
|
||||||
markers = "python_version < \"3.11\""
|
|
||||||
files = [
|
|
||||||
{file = "exceptiongroup-1.2.1-py3-none-any.whl", hash = "sha256:5258b9ed329c5bbdd31a309f53cbfb0b155341807f6ff7606a1e801a891b29ad"},
|
|
||||||
{file = "exceptiongroup-1.2.1.tar.gz", hash = "sha256:a4785e48b045528f5bfe627b6ad554ff32def154f42372786903b7abcfe1aa16"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
test = ["pytest (>=6)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "h11"
|
|
||||||
version = "0.16.0"
|
|
||||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
|
||||||
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpcore"
|
|
||||||
version = "1.0.9"
|
|
||||||
description = "A minimal low-level HTTP client."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
|
|
||||||
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
certifi = "*"
|
|
||||||
h11 = ">=0.16"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
asyncio = ["anyio (>=4.0,<5.0)"]
|
|
||||||
http2 = ["h2 (>=3,<5)"]
|
|
||||||
socks = ["socksio (==1.*)"]
|
|
||||||
trio = ["trio (>=0.22.0,<1.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpx"
|
|
||||||
version = "0.28.1"
|
|
||||||
description = "The next generation HTTP client."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
|
|
||||||
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
anyio = "*"
|
|
||||||
certifi = "*"
|
|
||||||
httpcore = "==1.*"
|
|
||||||
idna = "*"
|
|
||||||
|
|
||||||
[package.extras]
|
|
||||||
brotli = ["brotli", "brotlicffi"]
|
|
||||||
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
|
|
||||||
http2 = ["h2 (>=3,<5)"]
|
|
||||||
socks = ["socksio (==1.*)"]
|
|
||||||
zstd = ["zstandard (>=0.18.0)"]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "httpx-sse"
|
|
||||||
version = "0.4.0"
|
|
||||||
description = "Consume Server-Sent Event (SSE) messages with HTTPX."
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "httpx-sse-0.4.0.tar.gz", hash = "sha256:1e81a3a3070ce322add1d3529ed42eb5f70817f45ed6ec915ab753f961139721"},
|
|
||||||
{file = "httpx_sse-0.4.0-py3-none-any.whl", hash = "sha256:f329af6eae57eaa2bdfd962b42524764af68075ea87370a2de920af5341e318f"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "idna"
|
|
||||||
version = "3.7"
|
|
||||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.5"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "idna-3.7-py3-none-any.whl", hash = "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"},
|
|
||||||
{file = "idna-3.7.tar.gz", hash = "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "langgraph-cli"
|
|
||||||
version = "0.1.52"
|
|
||||||
description = "CLI for interacting with LangGraph API"
|
|
||||||
optional = false
|
|
||||||
python-versions = "^3.9.0,<4.0"
|
|
||||||
groups = ["main"]
|
|
||||||
files = []
|
|
||||||
develop = true
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
click = "^8.1.7"
|
|
||||||
|
|
||||||
[package.source]
|
|
||||||
type = "directory"
|
|
||||||
url = ".."
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "langgraph-sdk"
|
|
||||||
version = "0.1.29"
|
|
||||||
description = "SDK for interacting with LangGraph API"
|
|
||||||
optional = false
|
|
||||||
python-versions = "^3.9.0,<4.0"
|
|
||||||
groups = ["main"]
|
|
||||||
files = []
|
|
||||||
develop = true
|
|
||||||
|
|
||||||
[package.dependencies]
|
|
||||||
httpx = ">=0.25.2"
|
|
||||||
httpx-sse = ">=0.4.0"
|
|
||||||
orjson = ">=3.10.1"
|
|
||||||
|
|
||||||
[package.source]
|
|
||||||
type = "directory"
|
|
||||||
url = "../../sdk-py"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "orjson"
|
|
||||||
version = "3.10.5"
|
|
||||||
description = "Fast, correct Python JSON library supporting dataclasses, datetimes, and numpy"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "orjson-3.10.5-cp310-cp310-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:545d493c1f560d5ccfc134803ceb8955a14c3fcb47bbb4b2fee0232646d0b932"},
|
|
||||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f4324929c2dd917598212bfd554757feca3e5e0fa60da08be11b4aa8b90013c1"},
|
|
||||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8c13ca5e2ddded0ce6a927ea5a9f27cae77eee4c75547b4297252cb20c4d30e6"},
|
|
||||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b6c8e30adfa52c025f042a87f450a6b9ea29649d828e0fec4858ed5e6caecf63"},
|
|
||||||
{file = "orjson-3.10.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:338fd4f071b242f26e9ca802f443edc588fa4ab60bfa81f38beaedf42eda226c"},
|
|
||||||
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6970ed7a3126cfed873c5d21ece1cd5d6f83ca6c9afb71bbae21a0b034588d96"},
|
|
||||||
{file = "orjson-3.10.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:235dadefb793ad12f7fa11e98a480db1f7c6469ff9e3da5e73c7809c700d746b"},
|
|
||||||
{file = "orjson-3.10.5-cp310-none-win32.whl", hash = "sha256:be79e2393679eda6a590638abda16d167754393f5d0850dcbca2d0c3735cebe2"},
|
|
||||||
{file = "orjson-3.10.5-cp310-none-win_amd64.whl", hash = "sha256:c4a65310ccb5c9910c47b078ba78e2787cb3878cdded1702ac3d0da71ddc5228"},
|
|
||||||
{file = "orjson-3.10.5-cp311-cp311-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:cdf7365063e80899ae3a697def1277c17a7df7ccfc979990a403dfe77bb54d40"},
|
|
||||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b68742c469745d0e6ca5724506858f75e2f1e5b59a4315861f9e2b1df77775a"},
|
|
||||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7d10cc1b594951522e35a3463da19e899abe6ca95f3c84c69e9e901e0bd93d38"},
|
|
||||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dcbe82b35d1ac43b0d84072408330fd3295c2896973112d495e7234f7e3da2e1"},
|
|
||||||
{file = "orjson-3.10.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c0eb7e0c75e1e486c7563fe231b40fdd658a035ae125c6ba651ca3b07936f5"},
|
|
||||||
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:53ed1c879b10de56f35daf06dbc4a0d9a5db98f6ee853c2dbd3ee9d13e6f302f"},
|
|
||||||
{file = "orjson-3.10.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:099e81a5975237fda3100f918839af95f42f981447ba8f47adb7b6a3cdb078fa"},
|
|
||||||
{file = "orjson-3.10.5-cp311-none-win32.whl", hash = "sha256:1146bf85ea37ac421594107195db8bc77104f74bc83e8ee21a2e58596bfb2f04"},
|
|
||||||
{file = "orjson-3.10.5-cp311-none-win_amd64.whl", hash = "sha256:36a10f43c5f3a55c2f680efe07aa93ef4a342d2960dd2b1b7ea2dd764fe4a37c"},
|
|
||||||
{file = "orjson-3.10.5-cp312-cp312-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:68f85ecae7af14a585a563ac741b0547a3f291de81cd1e20903e79f25170458f"},
|
|
||||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28afa96f496474ce60d3340fe8d9a263aa93ea01201cd2bad844c45cd21f5268"},
|
|
||||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9cd684927af3e11b6e754df80b9ffafd9fb6adcaa9d3e8fdd5891be5a5cad51e"},
|
|
||||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d21b9983da032505f7050795e98b5d9eee0df903258951566ecc358f6696969"},
|
|
||||||
{file = "orjson-3.10.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ad1de7fef79736dde8c3554e75361ec351158a906d747bd901a52a5c9c8d24b"},
|
|
||||||
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2d97531cdfe9bdd76d492e69800afd97e5930cb0da6a825646667b2c6c6c0211"},
|
|
||||||
{file = "orjson-3.10.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d69858c32f09c3e1ce44b617b3ebba1aba030e777000ebdf72b0d8e365d0b2b3"},
|
|
||||||
{file = "orjson-3.10.5-cp312-none-win32.whl", hash = "sha256:64c9cc089f127e5875901ac05e5c25aa13cfa5dbbbd9602bda51e5c611d6e3e2"},
|
|
||||||
{file = "orjson-3.10.5-cp312-none-win_amd64.whl", hash = "sha256:b2efbd67feff8c1f7728937c0d7f6ca8c25ec81373dc8db4ef394c1d93d13dc5"},
|
|
||||||
{file = "orjson-3.10.5-cp38-cp38-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:03b565c3b93f5d6e001db48b747d31ea3819b89abf041ee10ac6988886d18e01"},
|
|
||||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:584c902ec19ab7928fd5add1783c909094cc53f31ac7acfada817b0847975f26"},
|
|
||||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a35455cc0b0b3a1eaf67224035f5388591ec72b9b6136d66b49a553ce9eb1e6"},
|
|
||||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1670fe88b116c2745a3a30b0f099b699a02bb3482c2591514baf5433819e4f4d"},
|
|
||||||
{file = "orjson-3.10.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:185c394ef45b18b9a7d8e8f333606e2e8194a50c6e3c664215aae8cf42c5385e"},
|
|
||||||
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:ca0b3a94ac8d3886c9581b9f9de3ce858263865fdaa383fbc31c310b9eac07c9"},
|
|
||||||
{file = "orjson-3.10.5-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dfc91d4720d48e2a709e9c368d5125b4b5899dced34b5400c3837dadc7d6271b"},
|
|
||||||
{file = "orjson-3.10.5-cp38-none-win32.whl", hash = "sha256:c05f16701ab2a4ca146d0bca950af254cb7c02f3c01fca8efbbad82d23b3d9d4"},
|
|
||||||
{file = "orjson-3.10.5-cp38-none-win_amd64.whl", hash = "sha256:8a11d459338f96a9aa7f232ba95679fc0c7cedbd1b990d736467894210205c09"},
|
|
||||||
{file = "orjson-3.10.5-cp39-cp39-macosx_10_15_x86_64.macosx_11_0_arm64.macosx_10_15_universal2.whl", hash = "sha256:85c89131d7b3218db1b24c4abecea92fd6c7f9fab87441cfc342d3acc725d807"},
|
|
||||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb66215277a230c456f9038d5e2d84778141643207f85336ef8d2a9da26bd7ca"},
|
|
||||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:51bbcdea96cdefa4a9b4461e690c75ad4e33796530d182bdd5c38980202c134a"},
|
|
||||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbead71dbe65f959b7bd8cf91e0e11d5338033eba34c114f69078d59827ee139"},
|
|
||||||
{file = "orjson-3.10.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5df58d206e78c40da118a8c14fc189207fffdcb1f21b3b4c9c0c18e839b5a214"},
|
|
||||||
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:c4057c3b511bb8aef605616bd3f1f002a697c7e4da6adf095ca5b84c0fd43595"},
|
|
||||||
{file = "orjson-3.10.5-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:b39e006b00c57125ab974362e740c14a0c6a66ff695bff44615dcf4a70ce2b86"},
|
|
||||||
{file = "orjson-3.10.5-cp39-none-win32.whl", hash = "sha256:eded5138cc565a9d618e111c6d5c2547bbdd951114eb822f7f6309e04db0fb47"},
|
|
||||||
{file = "orjson-3.10.5-cp39-none-win_amd64.whl", hash = "sha256:cc28e90a7cae7fcba2493953cff61da5a52950e78dc2dacfe931a317ee3d8de7"},
|
|
||||||
{file = "orjson-3.10.5.tar.gz", hash = "sha256:7a5baef8a4284405d96c90c7c62b755e9ef1ada84c2406c24a9ebec86b89f46d"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "sniffio"
|
|
||||||
version = "1.3.1"
|
|
||||||
description = "Sniff out which async library your code is running under"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.7"
|
|
||||||
groups = ["main"]
|
|
||||||
files = [
|
|
||||||
{file = "sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2"},
|
|
||||||
{file = "sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "typing-extensions"
|
|
||||||
version = "4.12.2"
|
|
||||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
|
||||||
optional = false
|
|
||||||
python-versions = ">=3.8"
|
|
||||||
groups = ["main"]
|
|
||||||
markers = "python_version < \"3.11\""
|
|
||||||
files = [
|
|
||||||
{file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"},
|
|
||||||
{file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"},
|
|
||||||
]
|
|
||||||
|
|
||||||
[metadata]
|
|
||||||
lock-version = "2.1"
|
|
||||||
python-versions = "^3.9.0,<4.0"
|
|
||||||
content-hash = "ec5109729f30d2033a10a10e8f8d3ed94c7d96d5d31025b4815b0123664bb063"
|
|
||||||
@@ -25,7 +25,9 @@
|
|||||||
"@langchain/langgraph": "^1.4.13"
|
"@langchain/langgraph": "^1.4.13"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"resolutions": {
|
||||||
"@langchain/langgraph-checkpoint": "1.0.4"
|
"@langchain/langgraph-checkpoint": "1.0.4",
|
||||||
|
"jest/**/js-yaml": "3.15.2",
|
||||||
|
"@eslint/eslintrc/js-yaml": "4.3.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.6",
|
"@eslint/eslintrc": "^3.3.6",
|
||||||
|
|||||||
@@ -3744,18 +3744,18 @@ js-tokens@^4.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499"
|
||||||
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==
|
||||||
|
|
||||||
js-yaml@^3.13.1:
|
js-yaml@3.15.2, js-yaml@^3.13.1:
|
||||||
version "3.14.2"
|
version "3.15.2"
|
||||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.14.2.tgz#77485ce1dd7f33c061fd1b16ecea23b55fcb04b0"
|
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.15.2.tgz#3f83823ac6be17f570f23b2ecdef3777ff5ea364"
|
||||||
integrity sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==
|
integrity sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse "^1.0.7"
|
argparse "^1.0.7"
|
||||||
esprima "^4.0.0"
|
esprima "^4.0.0"
|
||||||
|
|
||||||
js-yaml@^4.3.0:
|
js-yaml@4.3.2, js-yaml@^4.3.0:
|
||||||
version "4.3.1"
|
version "4.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848"
|
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb"
|
||||||
integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==
|
integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse "^2.0.1"
|
argparse "^2.0.1"
|
||||||
|
|
||||||
|
|||||||
@@ -1391,9 +1391,9 @@ js-tiktoken@^1.0.12:
|
|||||||
base64-js "^1.5.1"
|
base64-js "^1.5.1"
|
||||||
|
|
||||||
js-yaml@^4.3.0:
|
js-yaml@^4.3.0:
|
||||||
version "4.3.1"
|
version "4.3.2"
|
||||||
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.1.tgz#01216c001d67f48e2cd560d708c7af21090a3848"
|
resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb"
|
||||||
integrity sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==
|
integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==
|
||||||
dependencies:
|
dependencies:
|
||||||
argparse "^2.0.1"
|
argparse "^2.0.1"
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
__version__ = "0.4.31"
|
__version__ = "0.4.32"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import re
|
|||||||
import shlex
|
import shlex
|
||||||
import textwrap
|
import textwrap
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
|
from collections.abc import Iterable
|
||||||
from typing import Literal, NamedTuple
|
from typing import Literal, NamedTuple
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -36,6 +37,10 @@ DISALLOWED_BUILD_COMMAND_CHARS = [
|
|||||||
# This blocks background execution (cmd &) while allowing command
|
# This blocks background execution (cmd &) while allowing command
|
||||||
# chaining (cmd1 && cmd2) which is common in build commands.
|
# chaining (cmd1 && cmd2) which is common in build commands.
|
||||||
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
|
_SINGLE_AMPERSAND_RE = re.compile(r"(?<!&)&(?:&&)*(?!&)")
|
||||||
|
_GIT_HTTP_AUTHORITY_RES = (
|
||||||
|
re.compile(r"git\+https?://(?P<authority>[^/\s\"']+)", re.I),
|
||||||
|
re.compile(r"\bgit\s*=\s*[\"']https?://(?P<authority>[^/\s\"']+)", re.I),
|
||||||
|
)
|
||||||
_API_VERSION_PATTERN = re.compile(
|
_API_VERSION_PATTERN = re.compile(
|
||||||
r"^(?P<major>\d+)"
|
r"^(?P<major>\d+)"
|
||||||
r"(?:\.(?P<minor>\d+))?"
|
r"(?:\.(?P<minor>\d+))?"
|
||||||
@@ -78,6 +83,62 @@ def has_disallowed_build_command_content(command: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _has_git_http_url_userinfo(dependency: str) -> bool:
|
||||||
|
"""Check whether a Git HTTP URL contains userinfo."""
|
||||||
|
return any(
|
||||||
|
"@" in match.group("authority")
|
||||||
|
for pattern in _GIT_HTTP_AUTHORITY_RES
|
||||||
|
for match in pattern.finditer(dependency)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_git_http_url_userinfo(
|
||||||
|
values: Iterable[str], *, source: pathlib.Path | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Reject credential-bearing Git HTTP URLs without echoing their values."""
|
||||||
|
if not any(_has_git_http_url_userinfo(value) for value in values):
|
||||||
|
return
|
||||||
|
message = (
|
||||||
|
"Git dependency URLs must not contain credentials or other URL "
|
||||||
|
"userinfo because generated Dockerfiles and image layers can retain "
|
||||||
|
"them. Use a credential-free Git URL and provide short-lived "
|
||||||
|
"credentials through your build environment's secret-backed Git "
|
||||||
|
"credential helper."
|
||||||
|
)
|
||||||
|
if source is not None:
|
||||||
|
message += f" Found in: {source}"
|
||||||
|
raise click.UsageError(message)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_git_http_url_userinfo_files(paths: Iterable[pathlib.Path]) -> None:
|
||||||
|
"""Reject credential-bearing Git HTTP URLs in dependency files."""
|
||||||
|
for path in paths:
|
||||||
|
path = path.resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
contents = path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
except OSError:
|
||||||
|
raise click.UsageError(
|
||||||
|
f"Could not inspect dependency file for embedded credentials: {path}"
|
||||||
|
) from None
|
||||||
|
_validate_git_http_url_userinfo([contents], source=path)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_local_dependency_files(config_path: pathlib.Path, config: Config) -> None:
|
||||||
|
"""Validate dependency files copied into a non-uv Python image."""
|
||||||
|
paths: list[pathlib.Path] = []
|
||||||
|
for dependency in config["dependencies"]:
|
||||||
|
if not isinstance(dependency, str) or not dependency.startswith("."):
|
||||||
|
continue
|
||||||
|
root = (config_path.parent / dependency).resolve()
|
||||||
|
paths.extend(
|
||||||
|
root / name
|
||||||
|
for name in ("requirements.txt", "pyproject.toml", "setup.py", "setup.cfg")
|
||||||
|
)
|
||||||
|
_validate_git_http_url_userinfo_files(paths)
|
||||||
|
|
||||||
|
|
||||||
MIN_PYTHON_VERSION = "3.11"
|
MIN_PYTHON_VERSION = "3.11"
|
||||||
DEFAULT_PYTHON_VERSION = "3.11"
|
DEFAULT_PYTHON_VERSION = "3.11"
|
||||||
|
|
||||||
@@ -320,7 +381,9 @@ def _get_source_kind(config: Config) -> str | None:
|
|||||||
return kind if isinstance(kind, str) else None
|
return kind if isinstance(kind, str) else None
|
||||||
|
|
||||||
|
|
||||||
def validate_config(config: Config) -> Config:
|
def validate_config(
|
||||||
|
config: Config, *, source_path: pathlib.Path | None = None
|
||||||
|
) -> Config:
|
||||||
"""Validate a configuration dictionary."""
|
"""Validate a configuration dictionary."""
|
||||||
|
|
||||||
graphs = config.get("graphs", {})
|
graphs = config.get("graphs", {})
|
||||||
@@ -415,6 +478,15 @@ def validate_config(config: Config) -> Config:
|
|||||||
' "source": {"kind": "uv", "root": ".."}'
|
' "source": {"kind": "uv", "root": ".."}'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
_validate_git_http_url_userinfo(
|
||||||
|
(
|
||||||
|
dependency
|
||||||
|
for dependency in config["dependencies"]
|
||||||
|
if isinstance(dependency, str)
|
||||||
|
),
|
||||||
|
source=source_path,
|
||||||
|
)
|
||||||
|
|
||||||
source = config.get("source")
|
source = config.get("source")
|
||||||
source_kind = _get_source_kind(config)
|
source_kind = _get_source_kind(config)
|
||||||
if source is not None and not isinstance(source, dict):
|
if source is not None and not isinstance(source, dict):
|
||||||
@@ -609,7 +681,7 @@ def validate_config_file(config_path: pathlib.Path) -> Config:
|
|||||||
"""Load and validate a configuration file."""
|
"""Load and validate a configuration file."""
|
||||||
with open(config_path) as f:
|
with open(config_path) as f:
|
||||||
config = json.load(f)
|
config = json.load(f)
|
||||||
validated = validate_config(config)
|
validated = validate_config(config, source_path=config_path.resolve())
|
||||||
# Enforce the package.json doesn't enforce an
|
# Enforce the package.json doesn't enforce an
|
||||||
# incompatible Node.js version
|
# incompatible Node.js version
|
||||||
if validated.get("node_version"):
|
if validated.get("node_version"):
|
||||||
@@ -1280,6 +1352,7 @@ def python_config_to_docker(
|
|||||||
api_version=api_version,
|
api_version=api_version,
|
||||||
build_tools_to_uninstall=build_tools_to_uninstall,
|
build_tools_to_uninstall=build_tools_to_uninstall,
|
||||||
)
|
)
|
||||||
|
_validate_local_dependency_files(config_path, config)
|
||||||
if pip_installer == "auto":
|
if pip_installer == "auto":
|
||||||
if _image_supports_uv(base_image):
|
if _image_supports_uv(base_image):
|
||||||
pip_installer = "uv"
|
pip_installer = "uv"
|
||||||
@@ -1490,7 +1563,18 @@ def node_config_to_docker(
|
|||||||
) -> tuple[str, dict[str, str]]:
|
) -> tuple[str, dict[str, str]]:
|
||||||
# Calculate paths for monorepo support
|
# Calculate paths for monorepo support
|
||||||
install_root = (
|
install_root = (
|
||||||
pathlib.Path(build_context).resolve() if build_context else config_path.parent
|
pathlib.Path(build_context).resolve()
|
||||||
|
if build_context
|
||||||
|
else config_path.parent.resolve()
|
||||||
|
)
|
||||||
|
config_root = config_path.parent.resolve()
|
||||||
|
dependency_roots = (
|
||||||
|
(install_root, config_root) if install_root != config_root else (install_root,)
|
||||||
|
)
|
||||||
|
_validate_git_http_url_userinfo_files(
|
||||||
|
root / name
|
||||||
|
for root in dependency_roots
|
||||||
|
for name in ("package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml")
|
||||||
)
|
)
|
||||||
install_cmd = install_command or _get_node_pm_install_cmd(install_root)
|
install_cmd = install_command or _get_node_pm_install_cmd(install_root)
|
||||||
if build_context:
|
if build_context:
|
||||||
|
|||||||
+939
-349
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable, Coroutine
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from typing import cast
|
from typing import Any, Protocol, TypeVar, cast
|
||||||
|
|
||||||
import click.exceptions
|
import click.exceptions
|
||||||
|
|
||||||
|
_T = TypeVar("_T")
|
||||||
|
|
||||||
|
|
||||||
|
class CommandRunner(Protocol):
|
||||||
|
def run(self, coro: Coroutine[Any, Any, _T]) -> _T: ...
|
||||||
|
|
||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def Runner():
|
def Runner():
|
||||||
|
|||||||
@@ -2,18 +2,125 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from dataclasses import dataclass
|
||||||
|
from typing import Any, Literal
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
import click
|
import click
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
|
CLOUD_CONTROL_PLANE_URL = "https://api.host.langchain.com"
|
||||||
|
CLOUD_DASHBOARD_URL = "https://smith.langchain.com"
|
||||||
|
CLOUD_DOMAIN = "langchain.com"
|
||||||
|
CLOUD_API_HOST = "api.smith.langchain.com"
|
||||||
|
CLOUD_CONTROL_PLANE_HOST = "api.host.langchain.com"
|
||||||
|
CLOUD_DASHBOARD_HOST = "smith.langchain.com"
|
||||||
|
CONTROL_PLANE_PATH = "/api-host"
|
||||||
|
LANGSMITH_API_PATHS = ("/api/v1", "/api")
|
||||||
|
LOCAL_HOSTNAMES = ("localhost", "127.0.0.1")
|
||||||
|
MAX_PAGE_SIZE = 100
|
||||||
|
SourceName = Literal["internal_docker", "internal_source", "external_docker"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ControlPlaneEndpoints:
|
||||||
|
control_plane_url: str
|
||||||
|
dashboard_url: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def resolve(
|
||||||
|
cls, host_url: str | None, langsmith_endpoint: str | None
|
||||||
|
) -> ControlPlaneEndpoints:
|
||||||
|
if host_url:
|
||||||
|
return cls.from_control_plane_url(host_url)
|
||||||
|
if langsmith_endpoint:
|
||||||
|
return cls.from_langsmith_endpoint(langsmith_endpoint)
|
||||||
|
return cls(CLOUD_CONTROL_PLANE_URL, CLOUD_DASHBOARD_URL)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_cloud(self) -> bool:
|
||||||
|
hostname = urlparse(self.control_plane_url).hostname or ""
|
||||||
|
return hostname == CLOUD_CONTROL_PLANE_HOST or hostname.endswith(
|
||||||
|
f".{CLOUD_CONTROL_PLANE_HOST}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_control_plane_url(cls, url: str) -> ControlPlaneEndpoints:
|
||||||
|
control_plane_url = url.rstrip("/")
|
||||||
|
hostname = urlparse(control_plane_url).hostname or ""
|
||||||
|
if control_plane_url.endswith(CONTROL_PLANE_PATH):
|
||||||
|
return cls(control_plane_url, control_plane_url[: -len(CONTROL_PLANE_PATH)])
|
||||||
|
if hostname in LOCAL_HOSTNAMES:
|
||||||
|
return cls(control_plane_url, control_plane_url)
|
||||||
|
return cls(control_plane_url, _cloud_dashboard_for(hostname))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_langsmith_endpoint(cls, endpoint: str) -> ControlPlaneEndpoints:
|
||||||
|
parsed = urlparse(endpoint.rstrip("/"))
|
||||||
|
hostname = parsed.hostname or ""
|
||||||
|
if _is_cloud_host(hostname):
|
||||||
|
return cls.from_control_plane_url(
|
||||||
|
f"https://{_cloud_control_plane_host_for(hostname)}"
|
||||||
|
)
|
||||||
|
root = f"{parsed.scheme}://{parsed.netloc}{_without_api_path(parsed.path)}"
|
||||||
|
return cls(f"{root}{CONTROL_PLANE_PATH}", root)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_cloud_host(hostname: str) -> bool:
|
||||||
|
return hostname == CLOUD_DOMAIN or hostname.endswith(f".{CLOUD_DOMAIN}")
|
||||||
|
|
||||||
|
|
||||||
|
def _cloud_control_plane_host_for(langsmith_api_host: str) -> str:
|
||||||
|
if langsmith_api_host.endswith(f".{CLOUD_API_HOST}"):
|
||||||
|
region = langsmith_api_host[: -len(CLOUD_API_HOST)]
|
||||||
|
return f"{region}{CLOUD_CONTROL_PLANE_HOST}"
|
||||||
|
return CLOUD_CONTROL_PLANE_HOST
|
||||||
|
|
||||||
|
|
||||||
|
def _cloud_dashboard_for(control_plane_host: str) -> str:
|
||||||
|
if control_plane_host.endswith(f".{CLOUD_CONTROL_PLANE_HOST}"):
|
||||||
|
region = control_plane_host[: -len(CLOUD_CONTROL_PLANE_HOST) - 1]
|
||||||
|
return f"https://{region}.{CLOUD_DASHBOARD_HOST}"
|
||||||
|
return CLOUD_DASHBOARD_URL
|
||||||
|
|
||||||
|
|
||||||
|
def _without_api_path(path: str) -> str:
|
||||||
|
for api_path in LANGSMITH_API_PATHS:
|
||||||
|
if path.endswith(api_path):
|
||||||
|
return path[: -len(api_path)]
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _resources(payload: object) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return []
|
||||||
|
resources = payload.get("resources")
|
||||||
|
if not isinstance(resources, list):
|
||||||
|
return []
|
||||||
|
return [item for item in resources if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
class HostBackendError(click.ClickException):
|
class HostBackendError(click.ClickException):
|
||||||
"""Raised when the host backend returns an error response."""
|
"""Raised when the host backend returns an error response."""
|
||||||
|
|
||||||
def __init__(self, message: str, status_code: int | None = None):
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
status_code: int | None = None,
|
||||||
|
detail: str | None = None,
|
||||||
|
):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
|
self.detail = detail
|
||||||
|
|
||||||
|
|
||||||
|
def _error_detail(response: httpx.Response) -> str | None:
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
detail = body.get("detail") if isinstance(body, dict) else None
|
||||||
|
return detail if isinstance(detail, str) else None
|
||||||
|
|
||||||
|
|
||||||
class HostBackendClient:
|
class HostBackendClient:
|
||||||
@@ -24,24 +131,37 @@ class HostBackendClient:
|
|||||||
base_url: str,
|
base_url: str,
|
||||||
api_key: str,
|
api_key: str,
|
||||||
tenant_id: str | None = None,
|
tenant_id: str | None = None,
|
||||||
|
*,
|
||||||
|
transport: httpx.BaseTransport | None = None,
|
||||||
):
|
):
|
||||||
if not base_url:
|
if not base_url:
|
||||||
raise click.UsageError("Host backend URL is required")
|
raise click.UsageError("Host backend URL is required")
|
||||||
transport = httpx.HTTPTransport(retries=3)
|
|
||||||
headers: dict[str, str] = {
|
headers: dict[str, str] = {
|
||||||
"X-Api-Key": api_key,
|
"X-Api-Key": api_key,
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
}
|
}
|
||||||
if tenant_id:
|
if tenant_id:
|
||||||
headers["X-Tenant-ID"] = tenant_id
|
headers["X-Tenant-ID"] = tenant_id
|
||||||
self._base_url = base_url.rstrip("/")
|
self._endpoints = ControlPlaneEndpoints.from_control_plane_url(base_url)
|
||||||
|
self._base_url = self._endpoints.control_plane_url
|
||||||
self._client = httpx.Client(
|
self._client = httpx.Client(
|
||||||
base_url=self._base_url,
|
base_url=self._base_url,
|
||||||
headers=headers,
|
headers=headers,
|
||||||
transport=transport,
|
transport=transport or httpx.HTTPTransport(retries=3),
|
||||||
timeout=30,
|
timeout=30,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def base_url(self) -> str:
|
||||||
|
return self._base_url
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoints(self) -> ControlPlaneEndpoints:
|
||||||
|
return self._endpoints
|
||||||
|
|
||||||
|
def set_tenant(self, tenant_id: str) -> None:
|
||||||
|
self._client.headers["X-Tenant-ID"] = tenant_id
|
||||||
|
|
||||||
def _request(
|
def _request(
|
||||||
self,
|
self,
|
||||||
method: str,
|
method: str,
|
||||||
@@ -53,10 +173,12 @@ class HostBackendClient:
|
|||||||
resp = self._client.request(method, path, json=payload, params=params)
|
resp = self._client.request(method, path, json=payload, params=params)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
except httpx.HTTPStatusError as err:
|
except httpx.HTTPStatusError as err:
|
||||||
detail = err.response.text or str(err.response.status_code)
|
detail = _error_detail(err.response)
|
||||||
|
reason = detail or err.response.text or str(err.response.status_code)
|
||||||
raise HostBackendError(
|
raise HostBackendError(
|
||||||
f"{method} {path} failed with status {err.response.status_code}: {detail}",
|
f"{method} {path} failed with status {err.response.status_code}: {reason}",
|
||||||
status_code=err.response.status_code,
|
status_code=err.response.status_code,
|
||||||
|
detail=detail,
|
||||||
) from None
|
) from None
|
||||||
except httpx.TransportError as err:
|
except httpx.TransportError as err:
|
||||||
raise HostBackendError(str(err)) from None
|
raise HostBackendError(str(err)) from None
|
||||||
@@ -72,30 +194,52 @@ class HostBackendClient:
|
|||||||
|
|
||||||
def create_deployment(
|
def create_deployment(
|
||||||
self,
|
self,
|
||||||
name: str,
|
*,
|
||||||
deployment_type: str,
|
name: str | None,
|
||||||
source: str,
|
source: SourceName,
|
||||||
config_path: str | None = None,
|
source_config: dict[str, object],
|
||||||
|
source_revision_config: dict[str, object],
|
||||||
secrets: list[dict[str, str]] | None = None,
|
secrets: list[dict[str, str]] | None = None,
|
||||||
|
agent: dict[str, str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a deployment."""
|
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"name": name,
|
|
||||||
"source": source,
|
"source": source,
|
||||||
"source_config": {"deployment_type": deployment_type},
|
"source_config": source_config,
|
||||||
"source_revision_config": {},
|
"source_revision_config": source_revision_config,
|
||||||
}
|
}
|
||||||
if source == "internal_source" and config_path:
|
if agent is not None:
|
||||||
payload["source_revision_config"]["langgraph_config_path"] = config_path
|
payload["agent"] = agent
|
||||||
|
else:
|
||||||
|
payload["name"] = name
|
||||||
if secrets is not None:
|
if secrets is not None:
|
||||||
payload["secrets"] = secrets
|
payload["secrets"] = secrets
|
||||||
return self._request("POST", "/v2/deployments", payload)
|
return self._request("POST", "/v2/deployments", payload)
|
||||||
|
|
||||||
def list_deployments(self, name_contains: str = "") -> dict[str, Any]:
|
def list_deployments(
|
||||||
return self._request(
|
self,
|
||||||
"GET",
|
*,
|
||||||
"/v2/deployments",
|
name: str | None = None,
|
||||||
params={"name_contains": name_contains},
|
name_contains: str | None = None,
|
||||||
|
limit: int | None = None,
|
||||||
|
agent_id: str | None = None,
|
||||||
|
agent_environment: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
given = (
|
||||||
|
("name", name),
|
||||||
|
("name_contains", name_contains),
|
||||||
|
("limit", limit),
|
||||||
|
("agent_id", agent_id),
|
||||||
|
("agent_environment", agent_environment),
|
||||||
|
)
|
||||||
|
params = {key: value for key, value in given if value is not None}
|
||||||
|
return _resources(self._request("GET", "/v2/deployments", params=params))
|
||||||
|
|
||||||
|
def get_listener(self, listener_id: str) -> dict[str, Any]:
|
||||||
|
return self._request("GET", f"/v2/listeners/{listener_id}")
|
||||||
|
|
||||||
|
def list_listeners(self) -> list[dict[str, Any]]:
|
||||||
|
return _resources(
|
||||||
|
self._request("GET", "/v2/listeners", params={"limit": MAX_PAGE_SIZE})
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
|
def get_deployment(self, deployment_id: str) -> dict[str, Any]:
|
||||||
@@ -121,22 +265,21 @@ class HostBackendClient:
|
|||||||
self,
|
self,
|
||||||
deployment_id: str,
|
deployment_id: str,
|
||||||
image_uri: str,
|
image_uri: str,
|
||||||
|
*,
|
||||||
|
revision_source: SourceName | None,
|
||||||
secrets: list[dict[str, str]] | None = None,
|
secrets: list[dict[str, str]] | None = None,
|
||||||
tracked_packages: list[str] | None = None,
|
tracked_packages: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"revision_source": "internal_docker",
|
|
||||||
"source_revision_config": {"image_uri": image_uri},
|
"source_revision_config": {"image_uri": image_uri},
|
||||||
}
|
}
|
||||||
|
if revision_source is not None:
|
||||||
|
payload["revision_source"] = revision_source
|
||||||
if tracked_packages:
|
if tracked_packages:
|
||||||
payload["tracked_packages"] = tracked_packages
|
payload["tracked_packages"] = tracked_packages
|
||||||
if secrets is not None:
|
if secrets is not None:
|
||||||
payload["secrets"] = secrets
|
payload["secrets"] = secrets
|
||||||
return self._request(
|
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
|
||||||
"PATCH",
|
|
||||||
f"/v2/deployments/{deployment_id}",
|
|
||||||
payload,
|
|
||||||
)
|
|
||||||
|
|
||||||
def update_deployment_internal_source(
|
def update_deployment_internal_source(
|
||||||
self,
|
self,
|
||||||
@@ -171,10 +314,15 @@ class HostBackendClient:
|
|||||||
payload["secrets"] = secrets
|
payload["secrets"] = secrets
|
||||||
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
|
return self._request("PATCH", f"/v2/deployments/{deployment_id}", payload)
|
||||||
|
|
||||||
def list_revisions(self, deployment_id: str, limit: int = 1) -> dict[str, Any]:
|
def list_revisions(
|
||||||
return self._request(
|
self, deployment_id: str, limit: int = 1
|
||||||
"GET",
|
) -> list[dict[str, Any]]:
|
||||||
f"/v2/deployments/{deployment_id}/revisions?limit={limit}",
|
return _resources(
|
||||||
|
self._request(
|
||||||
|
"GET",
|
||||||
|
f"/v2/deployments/{deployment_id}/revisions",
|
||||||
|
params={"limit": limit},
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
|
def get_revision(self, deployment_id: str, revision_id: str) -> dict[str, Any]:
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, replace
|
||||||
|
|
||||||
|
DIGEST_SEPARATOR = "@sha256:"
|
||||||
|
DIGEST_MARKER = "@"
|
||||||
|
TAG_SEPARATOR = ":"
|
||||||
|
PATH_SEPARATOR = "/"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ImageReference:
|
||||||
|
repository: str
|
||||||
|
tag: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def parse(cls, reference: str) -> ImageReference:
|
||||||
|
if DIGEST_MARKER in reference:
|
||||||
|
raise ValueError(f"{reference!r} carries a digest and cannot be tagged")
|
||||||
|
path_start = reference.rfind(PATH_SEPARATOR) + 1
|
||||||
|
name, separator, tag = reference[path_start:].partition(TAG_SEPARATOR)
|
||||||
|
if not separator:
|
||||||
|
return cls(reference)
|
||||||
|
return cls(reference[:path_start] + name, tag)
|
||||||
|
|
||||||
|
def with_tag(self, tag: str) -> ImageReference:
|
||||||
|
return replace(self, tag=tag)
|
||||||
|
|
||||||
|
def matches_digest(self, repo_digest: str) -> bool:
|
||||||
|
return repo_digest.startswith(f"{self.repository}{DIGEST_SEPARATOR}")
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
if self.tag is None:
|
||||||
|
return self.repository
|
||||||
|
return f"{self.repository}{TAG_SEPARATOR}{self.tag}"
|
||||||
@@ -650,7 +650,8 @@ class Config(TypedDict, total=False):
|
|||||||
|
|
||||||
pip_config_file: str | None
|
pip_config_file: str | None
|
||||||
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
|
"""Optional. Path to a pip config file (e.g., "/etc/pip.conf" or "pip.ini") for controlling
|
||||||
package installation (custom indices, credentials, etc.).
|
package installation (custom indices, timeouts, etc.). The file is copied into the
|
||||||
|
generated image, so it must not contain credentials or other secrets.
|
||||||
|
|
||||||
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
|
Only relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.
|
||||||
"""
|
"""
|
||||||
@@ -689,6 +690,9 @@ class Config(TypedDict, total=False):
|
|||||||
- "." or "./src" if you have a local Python package
|
- "." or "./src" if you have a local Python package
|
||||||
- str (aka "anthropic") for a PyPI package
|
- str (aka "anthropic") for a PyPI package
|
||||||
- "git+https://github.com/org/repo.git@main" for a Git-based package
|
- "git+https://github.com/org/repo.git@main" for a Git-based package
|
||||||
|
Git HTTP URLs must not contain userinfo such as a username or token. For private
|
||||||
|
dependencies, provide short-lived credentials through the build environment's
|
||||||
|
secret-backed Git credential helper.
|
||||||
Defaults to an empty list, meaning no additional packages installed beyond your base environment.
|
Defaults to an empty list, meaning no additional packages installed beyond your base environment.
|
||||||
|
|
||||||
This field is not supported when `source.kind` is `uv`.
|
This field is not supported when `source.kind` is `uv`.
|
||||||
|
|||||||
@@ -880,6 +880,7 @@ def python_config_to_docker_uv_lock(
|
|||||||
_get_node_pm_install_cmd,
|
_get_node_pm_install_cmd,
|
||||||
_get_pip_cleanup_lines,
|
_get_pip_cleanup_lines,
|
||||||
_image_supports_uv,
|
_image_supports_uv,
|
||||||
|
_validate_git_http_url_userinfo_files,
|
||||||
docker_tag,
|
docker_tag,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -890,11 +891,20 @@ def python_config_to_docker_uv_lock(
|
|||||||
)
|
)
|
||||||
|
|
||||||
config_root = config_path.parent.resolve()
|
config_root = config_path.parent.resolve()
|
||||||
|
source_root = config["source"].get("root", ".")
|
||||||
|
project_root = (config_root / source_root).resolve()
|
||||||
|
_validate_git_http_url_userinfo_files(
|
||||||
|
[project_root / "pyproject.toml", project_root / "uv.lock"]
|
||||||
|
)
|
||||||
|
|
||||||
install_cmd = "uv pip install --system"
|
install_cmd = "uv pip install --system"
|
||||||
_, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
|
_, global_reqs_pip_install, pip_config_file_str = _build_python_install_commands(
|
||||||
config, install_cmd
|
config, install_cmd
|
||||||
)
|
)
|
||||||
plan = _plan_uv_lock_workspace(config_path, config)
|
plan = _plan_uv_lock_workspace(config_path, config)
|
||||||
|
_validate_git_http_url_userinfo_files(
|
||||||
|
package.pyproject_path for package in plan.install_order
|
||||||
|
)
|
||||||
|
|
||||||
_update_uv_lock_graph_paths(config_path, config, plan)
|
_update_uv_lock_graph_paths(config_path, config, plan)
|
||||||
for section, key in [
|
for section, key in [
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||||
},
|
},
|
||||||
"_INTERNAL_docker_tag": {
|
"_INTERNAL_docker_tag": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||||
},
|
},
|
||||||
"_INTERNAL_docker_tag": {
|
"_INTERNAL_docker_tag": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||||
},
|
},
|
||||||
"_INTERNAL_docker_tag": {
|
"_INTERNAL_docker_tag": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
@@ -270,7 +270,7 @@
|
|||||||
"type": "null"
|
"type": "null"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, credentials, etc.).\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
"description": "Optional. Path to a pip config file (e.g., \"/etc/pip.conf\" or \"pip.ini\") for controlling\npackage installation (custom indices, timeouts, etc.). The file is copied into the\ngenerated image, so it must not contain credentials or other secrets.\n\nOnly relevant if Python dependencies are installed via pip. If omitted, default pip settings are used.\n"
|
||||||
},
|
},
|
||||||
"_INTERNAL_docker_tag": {
|
"_INTERNAL_docker_tag": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
|
|||||||
@@ -382,20 +382,18 @@ def test_deploy_list_command(monkeypatch) -> None:
|
|||||||
|
|
||||||
def list_deployments(self, name_contains: str = ""):
|
def list_deployments(self, name_contains: str = ""):
|
||||||
captured["name_contains"] = name_contains
|
captured["name_contains"] = name_contains
|
||||||
return {
|
return [
|
||||||
"resources": [
|
{
|
||||||
{
|
"id": "dep-123",
|
||||||
"id": "dep-123",
|
"name": "alpha",
|
||||||
"name": "alpha",
|
"source_config": {"custom_url": "https://alpha.example.com"},
|
||||||
"source_config": {"custom_url": "https://alpha.example.com"},
|
},
|
||||||
},
|
{
|
||||||
{
|
"id": "dep-456",
|
||||||
"id": "dep-456",
|
"name": "beta",
|
||||||
"name": "beta",
|
"source_config": {"custom_url": "https://beta.example.com"},
|
||||||
"source_config": {"custom_url": "https://beta.example.com"},
|
},
|
||||||
},
|
]
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||||
|
|
||||||
@@ -435,7 +433,7 @@ def test_deploy_list_command_no_results(monkeypatch) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def list_deployments(self, name_contains: str = ""):
|
def list_deployments(self, name_contains: str = ""):
|
||||||
return {"resources": []}
|
return []
|
||||||
|
|
||||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||||
|
|
||||||
@@ -468,20 +466,18 @@ def test_deploy_revisions_list_command(monkeypatch) -> None:
|
|||||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||||
captured["deployment_id"] = deployment_id
|
captured["deployment_id"] = deployment_id
|
||||||
captured["limit"] = str(limit)
|
captured["limit"] = str(limit)
|
||||||
return {
|
return [
|
||||||
"resources": [
|
{
|
||||||
{
|
"id": "rev-123",
|
||||||
"id": "rev-123",
|
"status": "CREATING",
|
||||||
"status": "CREATING",
|
"created_at": "2023-11-07T05:31:56Z",
|
||||||
"created_at": "2023-11-07T05:31:56Z",
|
},
|
||||||
},
|
{
|
||||||
{
|
"id": "rev-456",
|
||||||
"id": "rev-456",
|
"status": "DEPLOYED",
|
||||||
"status": "DEPLOYED",
|
"created_at": "2023-11-08T10:00:00Z",
|
||||||
"created_at": "2023-11-08T10:00:00Z",
|
},
|
||||||
},
|
]
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||||
|
|
||||||
@@ -522,7 +518,7 @@ def test_deploy_revisions_list_command_no_results(monkeypatch) -> None:
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||||
return {"resources": []}
|
return []
|
||||||
|
|
||||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||||
|
|
||||||
@@ -555,7 +551,7 @@ def test_deploy_revisions_list_command_with_explicit_limit(monkeypatch) -> None:
|
|||||||
def list_revisions(self, deployment_id: str, limit: int = 1):
|
def list_revisions(self, deployment_id: str, limit: int = 1):
|
||||||
captured["deployment_id"] = deployment_id
|
captured["deployment_id"] = deployment_id
|
||||||
captured["limit"] = str(limit)
|
captured["limit"] = str(limit)
|
||||||
return {"resources": []}
|
return []
|
||||||
|
|
||||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -12,5 +12,5 @@ def disable_analytics_env() -> None:
|
|||||||
if "LANGGRAPH_CLI_NO_ANALYTICS" in os.environ:
|
if "LANGGRAPH_CLI_NO_ANALYTICS" in os.environ:
|
||||||
print("⚠️ LANGGRAPH_CLI_NO_ANALYTICS is set. Overriding it for the test.")
|
print("⚠️ LANGGRAPH_CLI_NO_ANALYTICS is set. Overriding it for the test.")
|
||||||
|
|
||||||
with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "0"}):
|
with patch.dict(os.environ, {"LANGGRAPH_CLI_NO_ANALYTICS": "1"}):
|
||||||
yield
|
yield
|
||||||
|
|||||||
@@ -255,6 +255,243 @@ def test_validate_config():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"dependency",
|
||||||
|
[
|
||||||
|
"git+https://user:secret-token@github.com/org/private.git@main",
|
||||||
|
"private-package @ git+http://token@github.com/org/private.git",
|
||||||
|
"git+HTTPS://user%40example.com:secret%2Ftoken@github.com/org/private.git",
|
||||||
|
"git+https://${GIT_TOKEN}@github.com/org/private.git",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_config_rejects_git_http_url_userinfo(dependency: str):
|
||||||
|
with pytest.raises(click.UsageError) as exc_info:
|
||||||
|
validate_config(
|
||||||
|
{
|
||||||
|
"python_version": "3.11",
|
||||||
|
"dependencies": [dependency],
|
||||||
|
"graphs": {"agent": "./agent.py:graph"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert "must not contain credentials or other URL userinfo" in message
|
||||||
|
assert "secret-token" not in message
|
||||||
|
assert "secret%2Ftoken" not in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_config_file_reports_source_for_git_http_url_userinfo(
|
||||||
|
tmp_path: pathlib.Path,
|
||||||
|
):
|
||||||
|
config_path = tmp_path / "langgraph.json"
|
||||||
|
config_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"python_version": "3.11",
|
||||||
|
"dependencies": ["git+https://secret-token@github.com/org/private.git"],
|
||||||
|
"graphs": {"agent": "./agent.py:graph"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(click.UsageError) as exc_info:
|
||||||
|
validate_config_file(config_path)
|
||||||
|
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert "secret-token" not in message
|
||||||
|
assert f"Found in: {config_path.resolve()}" in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"manifest", ["package.json", "package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
|
||||||
|
)
|
||||||
|
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_files(
|
||||||
|
tmp_path: pathlib.Path, manifest: str
|
||||||
|
):
|
||||||
|
config_path = tmp_path / "langgraph.json"
|
||||||
|
config_path.write_text("{}\n")
|
||||||
|
(tmp_path / "agent.js").write_text("export const graph = {};\n")
|
||||||
|
(tmp_path / "package.json").write_text('{"name":"agent"}\n')
|
||||||
|
(tmp_path / manifest).write_text(
|
||||||
|
'"priv": "git+https://user:secret-token@github.com/org/private.git"\n'
|
||||||
|
)
|
||||||
|
config = validate_config(
|
||||||
|
{
|
||||||
|
"node_version": "20",
|
||||||
|
"graphs": {"agent": "./agent.js:graph"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(click.UsageError) as exc_info:
|
||||||
|
config_to_docker(
|
||||||
|
config_path,
|
||||||
|
config,
|
||||||
|
base_image="langchain/langgraphjs-api",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert "must not contain credentials or other URL userinfo" in message
|
||||||
|
assert "secret-token" not in message
|
||||||
|
assert f"Found in: {(tmp_path / manifest).resolve()}" in message
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_to_docker_allows_node_git_urls_without_http_userinfo(
|
||||||
|
tmp_path: pathlib.Path,
|
||||||
|
):
|
||||||
|
config_path = tmp_path / "langgraph.json"
|
||||||
|
config_path.write_text("{}\n")
|
||||||
|
(tmp_path / "agent.js").write_text("export const graph = {};\n")
|
||||||
|
(tmp_path / "package.json").write_text(
|
||||||
|
'{"dependencies":{"public":"git+https://github.com/org/public.git"}}\n'
|
||||||
|
)
|
||||||
|
config = validate_config(
|
||||||
|
{
|
||||||
|
"node_version": "20",
|
||||||
|
"graphs": {"agent": "./agent.js:graph"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
docker, _ = config_to_docker(
|
||||||
|
config_path,
|
||||||
|
config,
|
||||||
|
base_image="langchain/langgraphjs-api",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert f"ADD . /deps/{tmp_path.name}" in docker
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_to_docker_rejects_git_http_url_userinfo_in_node_workspace(
|
||||||
|
tmp_path: pathlib.Path,
|
||||||
|
):
|
||||||
|
config_root = tmp_path / "apps" / "agent"
|
||||||
|
config_root.mkdir(parents=True)
|
||||||
|
config_path = config_root / "langgraph.json"
|
||||||
|
config_path.write_text("{}\n")
|
||||||
|
(config_root / "agent.js").write_text("export const graph = {};\n")
|
||||||
|
(config_root / "package.json").write_text(
|
||||||
|
'{"dependencies":{"priv":"git+https://secret-token@github.com/org/private.git"}}\n'
|
||||||
|
)
|
||||||
|
(tmp_path / "package.json").write_text('{"name":"workspace"}\n')
|
||||||
|
config = validate_config(
|
||||||
|
{
|
||||||
|
"node_version": "20",
|
||||||
|
"graphs": {"agent": "./agent.js:graph"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(click.UsageError) as exc_info:
|
||||||
|
config_to_docker(
|
||||||
|
config_path,
|
||||||
|
config,
|
||||||
|
base_image="langchain/langgraphjs-api",
|
||||||
|
build_context=str(tmp_path),
|
||||||
|
)
|
||||||
|
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert "secret-token" not in message
|
||||||
|
assert f"Found in: {(config_root / 'package.json').resolve()}" in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"dependency",
|
||||||
|
[
|
||||||
|
"git+https://github.com/org/public.git@main",
|
||||||
|
"private-package @ git+https://github.com/org/private.git@main",
|
||||||
|
"git+ssh://git@github.com/org/private.git@main",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_validate_config_allows_git_urls_without_http_userinfo(dependency: str):
|
||||||
|
config = validate_config(
|
||||||
|
{
|
||||||
|
"python_version": "3.11",
|
||||||
|
"dependencies": [dependency],
|
||||||
|
"graphs": {"agent": "./agent.py:graph"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert config["dependencies"] == [dependency]
|
||||||
|
|
||||||
|
|
||||||
|
def test_config_to_docker_rejects_git_http_url_userinfo_in_requirements(
|
||||||
|
tmp_path: pathlib.Path,
|
||||||
|
):
|
||||||
|
config_path = tmp_path / "langgraph.json"
|
||||||
|
config_path.write_text("{}\n")
|
||||||
|
(tmp_path / "agent.py").write_text("graph = object()\n")
|
||||||
|
(tmp_path / "requirements.txt").write_text(
|
||||||
|
"private @ git+https://secret-token@github.com/org/private.git\n"
|
||||||
|
)
|
||||||
|
config = validate_config(
|
||||||
|
{
|
||||||
|
"python_version": "3.11",
|
||||||
|
"dependencies": ["."],
|
||||||
|
"graphs": {"agent": "./agent.py:graph"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(click.UsageError) as exc_info:
|
||||||
|
config_to_docker(
|
||||||
|
config_path,
|
||||||
|
config,
|
||||||
|
base_image="langchain/langgraph-api:0.2.47",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert "must not contain credentials or other URL userinfo" in message
|
||||||
|
assert "secret-token" not in message
|
||||||
|
assert f"Found in: {(tmp_path / 'requirements.txt').resolve()}" in message
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("manifest", ["pyproject.toml", "uv.lock"])
|
||||||
|
def test_config_to_docker_rejects_git_http_url_userinfo_in_uv_files(
|
||||||
|
tmp_path: pathlib.Path, manifest: str
|
||||||
|
):
|
||||||
|
config_path = tmp_path / "langgraph.json"
|
||||||
|
config_path.write_text("{}\n")
|
||||||
|
(tmp_path / "src").mkdir()
|
||||||
|
(tmp_path / "src" / "agent.py").write_text("graph = object()\n")
|
||||||
|
pyproject = textwrap.dedent(
|
||||||
|
"""
|
||||||
|
[project]
|
||||||
|
name = "agent"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = ["private"]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
private = { git = "https://github.com/org/private.git" }
|
||||||
|
"""
|
||||||
|
).strip()
|
||||||
|
uv_lock = "# uv lock file\n"
|
||||||
|
if manifest == "pyproject.toml":
|
||||||
|
pyproject = pyproject.replace(
|
||||||
|
"https://github.com", "https://secret-token@github.com"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
uv_lock += (
|
||||||
|
'source = { git = "https://secret-token@github.com/org/private.git" }\n'
|
||||||
|
)
|
||||||
|
(tmp_path / "pyproject.toml").write_text(pyproject + "\n")
|
||||||
|
(tmp_path / "uv.lock").write_text(uv_lock)
|
||||||
|
config = validate_config(
|
||||||
|
{
|
||||||
|
"python_version": "3.11",
|
||||||
|
"graphs": {"agent": "./src/agent.py:graph"},
|
||||||
|
"source": {"kind": "uv"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(click.UsageError) as exc_info:
|
||||||
|
config_to_docker(
|
||||||
|
config_path,
|
||||||
|
config,
|
||||||
|
base_image="langchain/langgraph-api:0.2.47",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = str(exc_info.value)
|
||||||
|
assert "must not contain credentials or other URL userinfo" in message
|
||||||
|
assert "secret-token" not in message
|
||||||
|
|
||||||
|
|
||||||
def test_validate_config_image_distro():
|
def test_validate_config_image_distro():
|
||||||
"""Test validation of image_distro field."""
|
"""Test validation of image_distro field."""
|
||||||
# Valid image_distro values should work
|
# Valid image_distro values should work
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import json
|
||||||
|
from unittest.mock import Mock
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
from click.testing import CliRunner
|
||||||
|
|
||||||
|
import langgraph_cli.deploy as deploy
|
||||||
|
from langgraph_cli.cli import cli
|
||||||
|
from langgraph_cli.host_backend import HostBackendClient
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def deployment_api(monkeypatch, tmp_path):
|
||||||
|
monkeypatch.chdir(tmp_path)
|
||||||
|
monkeypatch.delenv("LANGSMITH_DEPLOYMENT_NAME", raising=False)
|
||||||
|
monkeypatch.setattr(deploy, "_emitter", None)
|
||||||
|
monkeypatch.setattr(deploy, "_no_input", False)
|
||||||
|
(tmp_path / "langgraph.json").write_text(
|
||||||
|
json.dumps({"dependencies": ["."], "graphs": {"agent": "./agent.py:graph"}})
|
||||||
|
)
|
||||||
|
(tmp_path / ".env").write_text("LANGSMITH_DEPLOYMENT_NAME=legacy\n")
|
||||||
|
requests = []
|
||||||
|
state = {"enabled": True, "resources": []}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
requests.append(request)
|
||||||
|
assert request.url.path == "/v2/deployments"
|
||||||
|
if request.method == "GET":
|
||||||
|
if not state["enabled"] and (
|
||||||
|
"agent_id" in request.url.params
|
||||||
|
or "agent_environment" in request.url.params
|
||||||
|
):
|
||||||
|
return httpx.Response(
|
||||||
|
400, text="Agent filters are not available for this tenant."
|
||||||
|
)
|
||||||
|
return httpx.Response(200, json={"resources": state["resources"]})
|
||||||
|
assert request.method == "POST"
|
||||||
|
return httpx.Response(200, json={"id": "runtime-id", "name": "server-name"})
|
||||||
|
|
||||||
|
client = HostBackendClient("https://api.example.com", "test-key")
|
||||||
|
client._client.close()
|
||||||
|
client._client = httpx.Client(
|
||||||
|
base_url="https://api.example.com",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
headers={"X-Api-Key": "test-key"},
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(deploy, "_create_host_backend_client", lambda *a, **kw: client)
|
||||||
|
monkeypatch.setattr(deploy, "find_tracked_packages", lambda *a: [])
|
||||||
|
remote_build = Mock(return_value=deploy.BuildResult())
|
||||||
|
monkeypatch.setattr(deploy, "_run_remote_build", remote_build)
|
||||||
|
monkeypatch.setattr(deploy, "_resolve_build_mode", lambda flag, **kw: (flag, None))
|
||||||
|
yield state, requests, remote_build
|
||||||
|
client._client.close()
|
||||||
|
|
||||||
|
|
||||||
|
AGENT_ARGS = [
|
||||||
|
"deploy",
|
||||||
|
"--agent-id",
|
||||||
|
"customer-support",
|
||||||
|
"--agent-environment",
|
||||||
|
"staging",
|
||||||
|
"--remote",
|
||||||
|
"--no-wait",
|
||||||
|
"--no-input",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_create(deployment_api, tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("LANGSMITH_DEPLOYMENT_NAME", "legacy")
|
||||||
|
_, requests, build = deployment_api
|
||||||
|
result = CliRunner().invoke(cli, AGENT_ARGS)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert dict(requests[0].url.params) == {
|
||||||
|
"agent_id": "customer-support",
|
||||||
|
"agent_environment": "staging",
|
||||||
|
"limit": "100",
|
||||||
|
}
|
||||||
|
payload = json.loads(requests[1].content)
|
||||||
|
assert payload["agent"] == {
|
||||||
|
"agent_id": "customer-support",
|
||||||
|
"environment": "staging",
|
||||||
|
}
|
||||||
|
assert "name" not in payload
|
||||||
|
assert build.call_args.kwargs["deployment_id"] == "runtime-id"
|
||||||
|
assert "server-name" in result.output
|
||||||
|
assert (tmp_path / ".env").read_text() == "LANGSMITH_DEPLOYMENT_NAME=legacy\n"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_update(deployment_api):
|
||||||
|
state, requests, build = deployment_api
|
||||||
|
state["resources"] = [{"id": "existing-id", "is_preview": False}]
|
||||||
|
result = CliRunner().invoke(cli, AGENT_ARGS)
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
assert len(requests) == 1
|
||||||
|
assert build.call_args.kwargs["deployment_id"] == "existing-id"
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_rejects_explicit_name(deployment_api, monkeypatch):
|
||||||
|
monkeypatch.setenv("LANGSMITH_DEPLOYMENT_NAME", "legacy")
|
||||||
|
_, requests, _ = deployment_api
|
||||||
|
result = CliRunner().invoke(cli, [*AGENT_ARGS, "--name", "legacy"])
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "cannot be combined" in result.output
|
||||||
|
assert not requests
|
||||||
|
|
||||||
|
|
||||||
|
def test_agent_lookup_refuses_a_control_plane_that_ignores_the_filter(deployment_api):
|
||||||
|
state, requests, _ = deployment_api
|
||||||
|
state["resources"] = [
|
||||||
|
{"id": "someone-elses", "is_preview": False},
|
||||||
|
{"id": "another", "is_preview": False},
|
||||||
|
]
|
||||||
|
|
||||||
|
result = CliRunner().invoke(cli, AGENT_ARGS)
|
||||||
|
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "does not filter deployments by agent" in result.output
|
||||||
|
assert len(requests) == 1
|
||||||
@@ -13,6 +13,17 @@ import pytest
|
|||||||
|
|
||||||
import langgraph_cli.deploy as deploy_mod
|
import langgraph_cli.deploy as deploy_mod
|
||||||
from langgraph_cli.deploy import (
|
from langgraph_cli.deploy import (
|
||||||
|
ById,
|
||||||
|
ByName,
|
||||||
|
CustomerRegistrySource,
|
||||||
|
DockerBuildCommand,
|
||||||
|
ExistingDeployment,
|
||||||
|
Listener,
|
||||||
|
ManagedRegistrySource,
|
||||||
|
OnListener,
|
||||||
|
RemoteBuildSource,
|
||||||
|
RequestedPlacement,
|
||||||
|
Unplaced,
|
||||||
_call_host_backend_with_optional_tenant,
|
_call_host_backend_with_optional_tenant,
|
||||||
_create_host_backend_client,
|
_create_host_backend_client,
|
||||||
_docker_config_for_token,
|
_docker_config_for_token,
|
||||||
@@ -21,12 +32,14 @@ from langgraph_cli.deploy import (
|
|||||||
_parse_env_from_config,
|
_parse_env_from_config,
|
||||||
_resolve_env_path,
|
_resolve_env_path,
|
||||||
_resolve_pushed_image_digest,
|
_resolve_pushed_image_digest,
|
||||||
_smith_dashboard_base_url,
|
_select_source,
|
||||||
_validate_prebuilt_image,
|
_validate_prebuilt_image,
|
||||||
|
find_deployment_by_name,
|
||||||
normalize_image_tag,
|
normalize_image_tag,
|
||||||
normalize_name,
|
normalize_name,
|
||||||
)
|
)
|
||||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
||||||
|
from langgraph_cli.image_reference import ImageReference
|
||||||
|
|
||||||
|
|
||||||
class TestDockerConfigForToken:
|
class TestDockerConfigForToken:
|
||||||
@@ -259,31 +272,29 @@ class TestEnvWithoutDeploymentName:
|
|||||||
|
|
||||||
class TestCallHostBackendWithOptionalTenant:
|
class TestCallHostBackendWithOptionalTenant:
|
||||||
def _make_client(self, handler):
|
def _make_client(self, handler):
|
||||||
c = HostBackendClient("https://api.example.com", "test-key")
|
c = HostBackendClient(
|
||||||
c._client = httpx.Client(
|
"https://api.example.com",
|
||||||
base_url="https://api.example.com",
|
"test-key",
|
||||||
transport=httpx.MockTransport(handler),
|
transport=httpx.MockTransport(handler),
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
return c
|
return c
|
||||||
|
|
||||||
def _make_eu_client(self, handler):
|
def _make_eu_client(self, handler):
|
||||||
c = HostBackendClient("https://eu.api.host.langchain.com", "test-key")
|
c = HostBackendClient(
|
||||||
c._client = httpx.Client(
|
"https://eu.api.host.langchain.com",
|
||||||
base_url="https://eu.api.host.langchain.com",
|
"test-key",
|
||||||
transport=httpx.MockTransport(handler),
|
transport=httpx.MockTransport(handler),
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
return c
|
return c
|
||||||
|
|
||||||
def test_success_passes_through(self):
|
def test_success_passes_through(self):
|
||||||
client = self._make_client(lambda req: httpx.Response(200, json={"ok": True}))
|
client = self._make_client(
|
||||||
|
lambda req: httpx.Response(200, json={"resources": [{"id": "dep-1"}]})
|
||||||
|
)
|
||||||
result = _call_host_backend_with_optional_tenant(
|
result = _call_host_backend_with_optional_tenant(
|
||||||
client, lambda c: c.list_deployments()
|
client, lambda c: c.list_deployments()
|
||||||
)
|
)
|
||||||
assert result == {"ok": True}
|
assert result == [{"id": "dep-1"}]
|
||||||
|
|
||||||
def test_403_not_enabled_gives_actionable_error(self):
|
def test_403_not_enabled_gives_actionable_error(self):
|
||||||
detail = (
|
detail = (
|
||||||
@@ -334,7 +345,6 @@ class TestCallHostBackendWithOptionalTenant:
|
|||||||
assert exc_info.value.status_code == 403
|
assert exc_info.value.status_code == 403
|
||||||
assert "smith.langchain.com" in exc_info.value.message
|
assert "smith.langchain.com" in exc_info.value.message
|
||||||
assert seen_tenant_ids == [None, "workspace-123"]
|
assert seen_tenant_ids == [None, "workspace-123"]
|
||||||
assert client._client.headers["X-Tenant-ID"] == "workspace-123"
|
|
||||||
|
|
||||||
def test_other_403_re_raises_original(self):
|
def test_other_403_re_raises_original(self):
|
||||||
client = self._make_client(
|
client = self._make_client(
|
||||||
@@ -540,60 +550,226 @@ class TestCreateHostBackendClientNoInput:
|
|||||||
assert client is not None
|
assert client is not None
|
||||||
|
|
||||||
|
|
||||||
class TestSmithDashboardBaseUrl:
|
class TestCreateHostBackendClientEndpoint:
|
||||||
def test_none_returns_default(self):
|
def test_langsmith_endpoint_from_project_env_selects_self_hosted_control_plane(
|
||||||
assert _smith_dashboard_base_url(None) == "https://smith.langchain.com"
|
self, monkeypatch
|
||||||
|
):
|
||||||
|
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||||
|
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
|
||||||
|
|
||||||
def test_empty_returns_default(self):
|
client = _create_host_backend_client(
|
||||||
assert _smith_dashboard_base_url("") == "https://smith.langchain.com"
|
host_url=None,
|
||||||
|
api_key=None,
|
||||||
def test_prod_host_url(self):
|
env_vars={"LANGSMITH_ENDPOINT": "https://smith.example.com/api/v1"},
|
||||||
assert (
|
|
||||||
_smith_dashboard_base_url("https://api.host.langchain.com")
|
|
||||||
== "https://smith.langchain.com"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_dev_host_url(self):
|
assert client.base_url == "https://smith.example.com/api-host"
|
||||||
assert (
|
|
||||||
_smith_dashboard_base_url("https://dev.api.host.langchain.com")
|
def test_explicit_host_url_wins_over_langsmith_endpoint(self, monkeypatch):
|
||||||
== "https://dev.smith.langchain.com"
|
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
||||||
|
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1")
|
||||||
|
|
||||||
|
client = _create_host_backend_client(
|
||||||
|
host_url="https://custom.host.com", api_key=None, env_vars={}
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_eu_host_url(self):
|
assert client.base_url == "https://custom.host.com"
|
||||||
assert (
|
|
||||||
_smith_dashboard_base_url("https://eu.api.host.langchain.com")
|
|
||||||
== "https://eu.smith.langchain.com"
|
class TestDockerBuildCommand:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("machine", "verbose", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
"x86_64",
|
||||||
|
False,
|
||||||
|
DockerBuildCommand(("docker", "build"), ()),
|
||||||
|
id="amd64_host_builds_natively",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"arm64",
|
||||||
|
False,
|
||||||
|
DockerBuildCommand(
|
||||||
|
("docker", "buildx", "build"),
|
||||||
|
("--platform", "linux/amd64", "--load", "--progress=quiet"),
|
||||||
|
),
|
||||||
|
id="other_hosts_cross_build_quietly",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"arm64",
|
||||||
|
True,
|
||||||
|
DockerBuildCommand(
|
||||||
|
("docker", "buildx", "build"),
|
||||||
|
("--platform", "linux/amd64", "--load"),
|
||||||
|
),
|
||||||
|
id="verbose_cross_build_keeps_progress_output",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_for_host_targets_the_deployment_platform(self, machine, verbose, expected):
|
||||||
|
assert DockerBuildCommand.for_host(machine, verbose=verbose) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestSelectSource:
|
||||||
|
OPTIONS = {
|
||||||
|
"push_to": None,
|
||||||
|
"image": None,
|
||||||
|
"image_name": None,
|
||||||
|
"tag": None,
|
||||||
|
"remote_build_flag": None,
|
||||||
|
"placement": RequestedPlacement(),
|
||||||
|
"selector": ByName("my-app"),
|
||||||
|
}
|
||||||
|
REPOSITORY = "registry.example.com/app"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("flags", "docker_available", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
{"push_to": REPOSITORY},
|
||||||
|
True,
|
||||||
|
CustomerRegistrySource(
|
||||||
|
reference=ImageReference(REPOSITORY, "latest"),
|
||||||
|
prebuilt_image=None,
|
||||||
|
requested_placement=RequestedPlacement(),
|
||||||
|
),
|
||||||
|
id="push_to_selects_the_external_source_with_the_default_tag",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"push_to": f"{REPOSITORY}:v2"},
|
||||||
|
True,
|
||||||
|
CustomerRegistrySource(
|
||||||
|
reference=ImageReference(REPOSITORY, "v2"),
|
||||||
|
prebuilt_image=None,
|
||||||
|
requested_placement=RequestedPlacement(),
|
||||||
|
),
|
||||||
|
id="push_to_keeps_a_tag_given_in_the_reference",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"push_to": REPOSITORY, "tag": "v3"},
|
||||||
|
True,
|
||||||
|
CustomerRegistrySource(
|
||||||
|
reference=ImageReference(REPOSITORY, "v3"),
|
||||||
|
prebuilt_image=None,
|
||||||
|
requested_placement=RequestedPlacement(),
|
||||||
|
),
|
||||||
|
id="tag_flag_composes_with_push_to",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"push_to": REPOSITORY, "image": "app:dev"},
|
||||||
|
False,
|
||||||
|
CustomerRegistrySource(
|
||||||
|
reference=ImageReference(REPOSITORY, "latest"),
|
||||||
|
prebuilt_image="app:dev",
|
||||||
|
requested_placement=RequestedPlacement(),
|
||||||
|
),
|
||||||
|
id="prebuilt_image_is_retagged_for_push_to_without_docker_checks",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
"push_to": REPOSITORY,
|
||||||
|
"placement": RequestedPlacement("listener-1", "agents"),
|
||||||
|
},
|
||||||
|
True,
|
||||||
|
CustomerRegistrySource(
|
||||||
|
reference=ImageReference(REPOSITORY, "latest"),
|
||||||
|
prebuilt_image=None,
|
||||||
|
requested_placement=RequestedPlacement("listener-1", "agents"),
|
||||||
|
),
|
||||||
|
id="push_to_carries_the_requested_placement",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"remote_build_flag": True},
|
||||||
|
True,
|
||||||
|
RemoteBuildSource(),
|
||||||
|
id="remote_flag_selects_the_source_upload",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{},
|
||||||
|
False,
|
||||||
|
RemoteBuildSource(),
|
||||||
|
id="no_local_docker_falls_back_to_the_source_upload",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{},
|
||||||
|
True,
|
||||||
|
ManagedRegistrySource(
|
||||||
|
prebuilt_image=None, image_name=None, tag="latest"
|
||||||
|
),
|
||||||
|
id="local_docker_selects_the_internal_docker_source",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"image": "app:dev", "tag": "v1"},
|
||||||
|
False,
|
||||||
|
ManagedRegistrySource(
|
||||||
|
prebuilt_image="app:dev", image_name=None, tag="v1"
|
||||||
|
),
|
||||||
|
id="prebuilt_image_forces_the_internal_docker_source",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_flags_select_one_source(
|
||||||
|
self, monkeypatch, mocker, flags, docker_available, expected
|
||||||
|
):
|
||||||
|
mocker.patch(
|
||||||
|
"langgraph_cli.deploy._get_emitter", return_value=mocker.MagicMock()
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
deploy_mod,
|
||||||
|
"can_build_locally",
|
||||||
|
lambda: (True, None) if docker_available else (False, "Docker is required"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_staging_host_url(self):
|
assert _select_source(**{**self.OPTIONS, **flags}) == expected
|
||||||
assert (
|
|
||||||
_smith_dashboard_base_url("https://staging.api.host.langchain.com")
|
def test_push_to_build_requires_local_docker(self, monkeypatch):
|
||||||
== "https://staging.smith.langchain.com"
|
monkeypatch.setattr(
|
||||||
|
deploy_mod, "can_build_locally", lambda: (False, "Docker is required")
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_localhost(self):
|
with pytest.raises(click.UsageError, match="Docker is required"):
|
||||||
assert (
|
_select_source(**{**self.OPTIONS, "push_to": self.REPOSITORY})
|
||||||
_smith_dashboard_base_url("http://localhost:8080")
|
|
||||||
== "http://localhost:8080"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_localhost_trailing_slash(self):
|
@pytest.mark.parametrize(
|
||||||
assert (
|
("flags", "message"),
|
||||||
_smith_dashboard_base_url("http://localhost:8080/")
|
[
|
||||||
== "http://localhost:8080"
|
pytest.param(
|
||||||
)
|
{"push_to": REPOSITORY, "remote_build_flag": True},
|
||||||
|
"--push-to cannot be combined with --remote.",
|
||||||
|
id="push_to_with_remote",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"push_to": f"{REPOSITORY}:v1", "tag": "v2"},
|
||||||
|
"already includes a tag",
|
||||||
|
id="push_to_with_a_tag_and_the_tag_flag",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"push_to": f"{REPOSITORY}@sha256:abc"},
|
||||||
|
"not a digest",
|
||||||
|
id="push_to_with_a_digest",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"image": "app:dev", "remote_build_flag": True},
|
||||||
|
"--image cannot be combined with --remote builds.",
|
||||||
|
id="image_with_remote",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"placement": RequestedPlacement(listener_id="listener-1")},
|
||||||
|
"only apply when creating a deployment with --push-to",
|
||||||
|
id="listener_without_push_to",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"placement": RequestedPlacement(k8s_namespace="agents")},
|
||||||
|
"only apply when creating a deployment with --push-to",
|
||||||
|
id="namespace_without_push_to",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_conflicting_flags_are_rejected(self, monkeypatch, flags, message):
|
||||||
|
monkeypatch.setattr(deploy_mod, "can_build_locally", lambda: (True, None))
|
||||||
|
|
||||||
def test_127_0_0_1(self):
|
with pytest.raises(click.UsageError, match=message):
|
||||||
assert (
|
_select_source(**{**self.OPTIONS, **flags})
|
||||||
_smith_dashboard_base_url("http://127.0.0.1:3000")
|
|
||||||
== "http://127.0.0.1:3000"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_unknown_domain_returns_default(self):
|
|
||||||
assert (
|
|
||||||
_smith_dashboard_base_url("https://custom.example.com")
|
|
||||||
== "https://smith.langchain.com"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolvePushedImageDigest:
|
class TestResolvePushedImageDigest:
|
||||||
@@ -644,6 +820,16 @@ class TestResolvePushedImageDigest:
|
|||||||
)
|
)
|
||||||
assert out == "us-central1-docker.pkg.dev/proj/repo@sha256:abc123"
|
assert out == "us-central1-docker.pkg.dev/proj/repo@sha256:abc123"
|
||||||
|
|
||||||
|
def test_registry_port_without_tag_still_resolves_the_digest(self):
|
||||||
|
runner = self._runner('["localhost:5000/repo@sha256:abc123"]')
|
||||||
|
out = _resolve_pushed_image_digest(
|
||||||
|
runner,
|
||||||
|
remote_image="localhost:5000/repo",
|
||||||
|
docker_config_dir=None,
|
||||||
|
verbose=False,
|
||||||
|
)
|
||||||
|
assert out == "localhost:5000/repo@sha256:abc123"
|
||||||
|
|
||||||
def test_empty_repodigests_falls_back_with_warning(self, mocker):
|
def test_empty_repodigests_falls_back_with_warning(self, mocker):
|
||||||
emitter = mocker.MagicMock()
|
emitter = mocker.MagicMock()
|
||||||
mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
|
mocker.patch("langgraph_cli.deploy._get_emitter", return_value=emitter)
|
||||||
@@ -747,3 +933,289 @@ class TestResolvePushedImageDigest:
|
|||||||
frame_locals = captured["coro"].cr_frame.f_locals
|
frame_locals = captured["coro"].cr_frame.f_locals
|
||||||
assert "--config" not in frame_locals["args"]
|
assert "--config" not in frame_locals["args"]
|
||||||
captured["coro"].close()
|
captured["coro"].close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestListener:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("resource", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
{
|
||||||
|
"id": "listener-1",
|
||||||
|
"compute_id": "prod-cluster",
|
||||||
|
"compute_config": {"k8s_namespaces": ["agents", "agents-staging"]},
|
||||||
|
},
|
||||||
|
Listener("listener-1", "prod-cluster", ("agents", "agents-staging")),
|
||||||
|
id="reads_id_cluster_and_namespaces",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"id": "listener-1", "compute_id": "c", "compute_config": {}},
|
||||||
|
Listener("listener-1", "c", ()),
|
||||||
|
id="missing_namespaces",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"id": "listener-1", "compute_id": "c", "compute_config": None},
|
||||||
|
Listener("listener-1", "c", ()),
|
||||||
|
id="null_compute_config",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"id": "listener-1"},
|
||||||
|
Listener("listener-1", "", ()),
|
||||||
|
id="only_an_id",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_from_resource_reads_the_control_plane_shape(self, resource, expected):
|
||||||
|
assert Listener.from_resource(resource) == expected
|
||||||
|
|
||||||
|
|
||||||
|
ONE_NAMESPACE = Listener("listener-1", "prod-cluster", ("agents",))
|
||||||
|
TWO_NAMESPACES = Listener("listener-2", "multi-cluster", ("agents", "agents-staging"))
|
||||||
|
NO_NAMESPACE = Listener("listener-3", "broken-cluster", ())
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequestedPlacement:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("request_", "listeners", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(), (), Unplaced(), id="no_listeners_no_request"
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(),
|
||||||
|
(ONE_NAMESPACE,),
|
||||||
|
OnListener("listener-1", "agents"),
|
||||||
|
id="uses_the_only_possible_answer",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(k8s_namespace="agents-staging"),
|
||||||
|
(TWO_NAMESPACES,),
|
||||||
|
OnListener("listener-2", "agents-staging"),
|
||||||
|
id="namespace_alone_picks_the_only_listener",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_resolves_to_a_placement(self, request_, listeners, expected):
|
||||||
|
assert request_.among(listeners) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("request_", "listeners", "message"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(listener_id="listener-1"),
|
||||||
|
(),
|
||||||
|
"no listeners",
|
||||||
|
id="workspace_has_no_listeners",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(),
|
||||||
|
(ONE_NAMESPACE, TWO_NAMESPACES),
|
||||||
|
"--listener-id",
|
||||||
|
id="several_listeners_need_a_choice",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(k8s_namespace="agents"),
|
||||||
|
(ONE_NAMESPACE, TWO_NAMESPACES),
|
||||||
|
"--listener-id",
|
||||||
|
id="namespace_alone_is_ambiguous_with_several_listeners",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(k8s_namespace="agents"),
|
||||||
|
(),
|
||||||
|
"no listeners",
|
||||||
|
id="namespace_without_any_listener",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(),
|
||||||
|
(TWO_NAMESPACES,),
|
||||||
|
"--k8s-namespace",
|
||||||
|
id="several_namespaces_need_a_choice",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_refuses_and_names_the_choices(self, request_, listeners, message):
|
||||||
|
with pytest.raises(click.UsageError, match=message):
|
||||||
|
request_.among(listeners)
|
||||||
|
|
||||||
|
def test_the_error_lists_every_listener_with_its_cluster_and_namespaces(self):
|
||||||
|
with pytest.raises(click.UsageError) as error:
|
||||||
|
RequestedPlacement().among((ONE_NAMESPACE, TWO_NAMESPACES))
|
||||||
|
|
||||||
|
assert "listener-1" in error.value.message
|
||||||
|
assert "prod-cluster" in error.value.message
|
||||||
|
assert "agents-staging" in error.value.message
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("placement", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(Unplaced(), {}, id="unplaced_adds_nothing"),
|
||||||
|
pytest.param(
|
||||||
|
OnListener("listener-1", "agents"),
|
||||||
|
{
|
||||||
|
"listener_id": "listener-1",
|
||||||
|
"listener_config": {"k8s_namespace": "agents"},
|
||||||
|
},
|
||||||
|
id="placed_carries_listener_and_namespace",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_source_config_matches_the_control_plane_shape(self, placement, expected):
|
||||||
|
assert placement.source_config() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_finding_a_deployment_by_name_narrows_the_search_for_every_server_version():
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
seen["params"] = dict(req.url.params)
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"resources": [{"id": "dep-1", "name": "agent", "source": "github"}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = HostBackendClient(
|
||||||
|
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
|
||||||
|
found = find_deployment_by_name(client, "agent")
|
||||||
|
|
||||||
|
assert seen["params"] == {
|
||||||
|
"name": "agent",
|
||||||
|
"name_contains": "agent",
|
||||||
|
"limit": "100",
|
||||||
|
}
|
||||||
|
assert found == ExistingDeployment("dep-1", "github")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_server_that_ignores_the_exact_name_filter_never_matches_another_deployment():
|
||||||
|
client = HostBackendClient(
|
||||||
|
"https://api.example.com",
|
||||||
|
"key",
|
||||||
|
transport=httpx.MockTransport(
|
||||||
|
lambda req: httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"resources": [
|
||||||
|
{
|
||||||
|
"id": "dep-other",
|
||||||
|
"name": "another-teams-agent",
|
||||||
|
"source": "external_docker",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert find_deployment_by_name(client, "brand-new-agent") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_full_page_without_a_match_refuses_to_claim_the_name_is_free():
|
||||||
|
page = [
|
||||||
|
{"id": f"dep-{index}", "name": f"other-agent-{index}"} for index in range(100)
|
||||||
|
]
|
||||||
|
client = HostBackendClient(
|
||||||
|
"https://api.example.com",
|
||||||
|
"key",
|
||||||
|
transport=httpx.MockTransport(
|
||||||
|
lambda req: httpx.Response(200, json={"resources": page})
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(click.ClickException, match="--deployment-id"):
|
||||||
|
find_deployment_by_name(client, "brand-new-agent")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_partial_page_without_a_match_means_the_name_is_free():
|
||||||
|
client = HostBackendClient(
|
||||||
|
"https://api.example.com",
|
||||||
|
"key",
|
||||||
|
transport=httpx.MockTransport(
|
||||||
|
lambda req: httpx.Response(
|
||||||
|
200, json={"resources": [{"id": "dep-1", "name": "other"}]}
|
||||||
|
)
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert find_deployment_by_name(client, "brand-new-agent") is None
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"resource",
|
||||||
|
[
|
||||||
|
pytest.param({"compute_id": "c"}, id="no_id"),
|
||||||
|
pytest.param({"id": ""}, id="empty_id"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_a_listener_without_an_id_is_refused(resource):
|
||||||
|
with pytest.raises(HostBackendError, match="without an id"):
|
||||||
|
Listener.from_resource(resource)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_deployment_id_with_listener_flags_is_refused_without_probing_docker(
|
||||||
|
monkeypatch,
|
||||||
|
):
|
||||||
|
def explode() -> tuple[bool, str | None]:
|
||||||
|
raise AssertionError("docker must not be probed for an argv-only conflict")
|
||||||
|
|
||||||
|
monkeypatch.setattr(deploy_mod, "can_build_locally", explode)
|
||||||
|
|
||||||
|
with pytest.raises(click.UsageError, match="--deployment-id"):
|
||||||
|
_select_source(
|
||||||
|
push_to="registry.example.com/app",
|
||||||
|
image=None,
|
||||||
|
image_name=None,
|
||||||
|
tag=None,
|
||||||
|
remote_build_flag=None,
|
||||||
|
placement=RequestedPlacement(listener_id="listener-1"),
|
||||||
|
selector=ById("dep-1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlacementOnAKnownListener:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("request_", "listener", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(listener_id="listener-1"),
|
||||||
|
ONE_NAMESPACE,
|
||||||
|
OnListener("listener-1", "agents"),
|
||||||
|
id="the_only_namespace_is_used",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(listener_id="listener-2", k8s_namespace="agents"),
|
||||||
|
TWO_NAMESPACES,
|
||||||
|
OnListener("listener-2", "agents"),
|
||||||
|
id="the_chosen_namespace_is_used",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_places_on_the_listener(self, request_, listener, expected):
|
||||||
|
assert request_.on(listener) == expected
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("request_", "listener", "message"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(listener_id="listener-2"),
|
||||||
|
TWO_NAMESPACES,
|
||||||
|
"--k8s-namespace",
|
||||||
|
id="several_namespaces_need_a_choice",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(listener_id="listener-2", k8s_namespace="nope"),
|
||||||
|
TWO_NAMESPACES,
|
||||||
|
"does not serve namespace",
|
||||||
|
id="unknown_namespace",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
RequestedPlacement(listener_id="listener-3"),
|
||||||
|
NO_NAMESPACE,
|
||||||
|
"serves no namespaces",
|
||||||
|
id="listener_without_namespaces",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_refuses_and_names_the_namespaces(self, request_, listener, message):
|
||||||
|
with pytest.raises(click.UsageError, match=message):
|
||||||
|
request_.on(listener)
|
||||||
|
|||||||
@@ -3,29 +3,16 @@ import json
|
|||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from langgraph_cli.host_backend import HostBackendClient, HostBackendError
|
from langgraph_cli.host_backend import (
|
||||||
|
ControlPlaneEndpoints,
|
||||||
|
HostBackendClient,
|
||||||
@pytest.fixture
|
HostBackendError,
|
||||||
def mock_transport():
|
)
|
||||||
return httpx.MockTransport(lambda req: httpx.Response(200, json={"ok": True}))
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
|
||||||
def client(mock_transport):
|
|
||||||
c = HostBackendClient("https://api.example.com", "test-key")
|
|
||||||
c._client = httpx.Client(
|
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=mock_transport,
|
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
return c
|
|
||||||
|
|
||||||
|
|
||||||
def test_constructor_strips_trailing_slash():
|
def test_constructor_strips_trailing_slash():
|
||||||
c = HostBackendClient("https://api.example.com/", "key")
|
c = HostBackendClient("https://api.example.com/", "key")
|
||||||
assert str(c._client.base_url) == "https://api.example.com"
|
assert c.base_url == "https://api.example.com"
|
||||||
|
|
||||||
|
|
||||||
def test_constructor_empty_url_raises():
|
def test_constructor_empty_url_raises():
|
||||||
@@ -39,12 +26,8 @@ def test_request_sends_headers():
|
|||||||
assert req.headers["accept"] == "application/json"
|
assert req.headers["accept"] == "application/json"
|
||||||
return httpx.Response(200, json={"ok": True})
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
|
||||||
c = HostBackendClient("https://api.example.com", "test-key")
|
c = HostBackendClient(
|
||||||
c._client = httpx.Client(
|
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
result = c._request("GET", "/test")
|
result = c._request("GET", "/test")
|
||||||
assert result == {"ok": True}
|
assert result == {"ok": True}
|
||||||
@@ -56,12 +39,8 @@ def test_request_sends_json_payload():
|
|||||||
assert req.content == b'{"key":"value"}'
|
assert req.content == b'{"key":"value"}'
|
||||||
return httpx.Response(200, json={"created": True})
|
return httpx.Response(200, json={"created": True})
|
||||||
|
|
||||||
c = HostBackendClient("https://api.example.com", "test-key")
|
c = HostBackendClient(
|
||||||
c._client = httpx.Client(
|
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
result = c._request("POST", "/test", {"key": "value"})
|
result = c._request("POST", "/test", {"key": "value"})
|
||||||
assert result == {"created": True}
|
assert result == {"created": True}
|
||||||
@@ -69,25 +48,13 @@ def test_request_sends_json_payload():
|
|||||||
|
|
||||||
def test_request_empty_body_returns_none():
|
def test_request_empty_body_returns_none():
|
||||||
transport = httpx.MockTransport(lambda req: httpx.Response(200, content=b""))
|
transport = httpx.MockTransport(lambda req: httpx.Response(200, content=b""))
|
||||||
c = HostBackendClient("https://api.example.com", "test-key")
|
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
|
||||||
c._client = httpx.Client(
|
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=transport,
|
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
assert c._request("DELETE", "/test") is None
|
assert c._request("DELETE", "/test") is None
|
||||||
|
|
||||||
|
|
||||||
def test_request_http_error_raises():
|
def test_request_http_error_raises():
|
||||||
transport = httpx.MockTransport(lambda req: httpx.Response(404, text="not found"))
|
transport = httpx.MockTransport(lambda req: httpx.Response(404, text="not found"))
|
||||||
c = HostBackendClient("https://api.example.com", "test-key")
|
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
|
||||||
c._client = httpx.Client(
|
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=transport,
|
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
with pytest.raises(HostBackendError, match="404"):
|
with pytest.raises(HostBackendError, match="404"):
|
||||||
c._request("GET", "/missing")
|
c._request("GET", "/missing")
|
||||||
|
|
||||||
@@ -96,13 +63,7 @@ def test_request_invalid_json_raises():
|
|||||||
transport = httpx.MockTransport(
|
transport = httpx.MockTransport(
|
||||||
lambda req: httpx.Response(200, content=b"not json")
|
lambda req: httpx.Response(200, content=b"not json")
|
||||||
)
|
)
|
||||||
c = HostBackendClient("https://api.example.com", "test-key")
|
c = HostBackendClient("https://api.example.com", "test-key", transport=transport)
|
||||||
c._client = httpx.Client(
|
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=transport,
|
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
with pytest.raises(HostBackendError, match="Failed to decode"):
|
with pytest.raises(HostBackendError, match="Failed to decode"):
|
||||||
c._request("GET", "/bad-json")
|
c._request("GET", "/bad-json")
|
||||||
|
|
||||||
@@ -111,84 +72,20 @@ def test_request_transport_error_raises():
|
|||||||
def handler(req: httpx.Request) -> httpx.Response:
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
raise httpx.ConnectError("connection refused")
|
raise httpx.ConnectError("connection refused")
|
||||||
|
|
||||||
c = HostBackendClient("https://api.example.com", "test-key")
|
c = HostBackendClient(
|
||||||
c._client = httpx.Client(
|
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
with pytest.raises(HostBackendError, match="connection refused"):
|
with pytest.raises(HostBackendError, match="connection refused"):
|
||||||
c._request("GET", "/test")
|
c._request("GET", "/test")
|
||||||
|
|
||||||
|
|
||||||
def test_create_deployment(client):
|
|
||||||
result = client.create_deployment(
|
|
||||||
name="my-deploy", deployment_type="dev", source="internal_docker"
|
|
||||||
)
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_deployment(client):
|
|
||||||
result = client.get_deployment("dep-123")
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_deployments(client):
|
|
||||||
result = client.list_deployments("my-app")
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_deployments_sends_query_params():
|
|
||||||
def handler(req: httpx.Request) -> httpx.Response:
|
|
||||||
assert req.url.path == "/v2/deployments"
|
|
||||||
assert req.url.params["name_contains"] == "my app"
|
|
||||||
return httpx.Response(200, json={"ok": True})
|
|
||||||
|
|
||||||
c = HostBackendClient("https://api.example.com", "test-key")
|
|
||||||
c._client = httpx.Client(
|
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
headers={"X-Api-Key": "test-key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
|
||||||
result = c.list_deployments("my app")
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_deployment(client):
|
|
||||||
result = client.delete_deployment("dep-123")
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_push_token(client):
|
|
||||||
result = client.request_push_token("dep-123")
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_deployment(client):
|
|
||||||
result = client.update_deployment(
|
|
||||||
"dep-123", "image:latest", secrets=[{"name": "KEY", "value": "val"}]
|
|
||||||
)
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_deployment_no_secrets(client):
|
|
||||||
result = client.update_deployment("dep-123", "image:latest")
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def _capturing_client(captured: dict) -> HostBackendClient:
|
def _capturing_client(captured: dict) -> HostBackendClient:
|
||||||
def handler(req: httpx.Request) -> httpx.Response:
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
captured["body"] = req.read()
|
captured["body"] = req.read()
|
||||||
return httpx.Response(200, json={"ok": True})
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
|
||||||
c = HostBackendClient("https://api.example.com", "key")
|
c = HostBackendClient(
|
||||||
c._client = httpx.Client(
|
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
return c
|
return c
|
||||||
|
|
||||||
@@ -199,6 +96,7 @@ def test_update_deployment_forwards_tracked_packages():
|
|||||||
c.update_deployment(
|
c.update_deployment(
|
||||||
"dep-123",
|
"dep-123",
|
||||||
"image:latest",
|
"image:latest",
|
||||||
|
revision_source="internal_docker",
|
||||||
tracked_packages=["google-adk:1.0.0"],
|
tracked_packages=["google-adk:1.0.0"],
|
||||||
)
|
)
|
||||||
body = json.loads(captured["body"])
|
body = json.loads(captured["body"])
|
||||||
@@ -209,7 +107,7 @@ def test_update_deployment_forwards_tracked_packages():
|
|||||||
def test_update_deployment_omits_tracked_packages_when_absent():
|
def test_update_deployment_omits_tracked_packages_when_absent():
|
||||||
captured: dict = {}
|
captured: dict = {}
|
||||||
c = _capturing_client(captured)
|
c = _capturing_client(captured)
|
||||||
c.update_deployment("dep-123", "image:latest")
|
c.update_deployment("dep-123", "image:latest", revision_source="internal_docker")
|
||||||
body = json.loads(captured["body"])
|
body = json.loads(captured["body"])
|
||||||
assert "tracked_packages" not in body
|
assert "tracked_packages" not in body
|
||||||
|
|
||||||
@@ -241,33 +139,14 @@ def test_update_deployment_internal_source_omits_tracked_packages_when_absent():
|
|||||||
assert "tracked_packages" not in body
|
assert "tracked_packages" not in body
|
||||||
|
|
||||||
|
|
||||||
def test_list_revisions(client):
|
|
||||||
result = client.list_revisions("dep-123", limit=5)
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_revision(client):
|
|
||||||
result = client.get_revision("dep-123", "rev-456")
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_build_logs(client):
|
|
||||||
result = client.get_build_logs("proj-1", "rev-1", {"limit": 10})
|
|
||||||
assert result == {"ok": True}
|
|
||||||
|
|
||||||
|
|
||||||
def test_get_deploy_logs_all_revisions():
|
def test_get_deploy_logs_all_revisions():
|
||||||
def handler(req: httpx.Request) -> httpx.Response:
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
assert "/v1/projects/proj-1/deploy_logs" in str(req.url)
|
assert "/v1/projects/proj-1/deploy_logs" in str(req.url)
|
||||||
assert "/revisions/" not in str(req.url)
|
assert "/revisions/" not in str(req.url)
|
||||||
return httpx.Response(200, json={"logs": [{"message": "running"}]})
|
return httpx.Response(200, json={"logs": [{"message": "running"}]})
|
||||||
|
|
||||||
c = HostBackendClient("https://api.example.com", "key")
|
c = HostBackendClient(
|
||||||
c._client = httpx.Client(
|
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
result = c.get_deploy_logs("proj-1", {"limit": 10})
|
result = c.get_deploy_logs("proj-1", {"limit": 10})
|
||||||
assert result == {"logs": [{"message": "running"}]}
|
assert result == {"logs": [{"message": "running"}]}
|
||||||
@@ -278,12 +157,520 @@ def test_get_deploy_logs_specific_revision():
|
|||||||
assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url)
|
assert "/v1/projects/proj-1/revisions/rev-2/deploy_logs" in str(req.url)
|
||||||
return httpx.Response(200, json={"logs": []})
|
return httpx.Response(200, json={"logs": []})
|
||||||
|
|
||||||
c = HostBackendClient("https://api.example.com", "key")
|
c = HostBackendClient(
|
||||||
c._client = httpx.Client(
|
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||||
base_url="https://api.example.com",
|
|
||||||
transport=httpx.MockTransport(handler),
|
|
||||||
headers={"X-Api-Key": "key", "Accept": "application/json"},
|
|
||||||
timeout=30,
|
|
||||||
)
|
)
|
||||||
result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2")
|
result = c.get_deploy_logs("proj-1", {"limit": 10}, revision_id="rev-2")
|
||||||
assert result == {"logs": []}
|
assert result == {"logs": []}
|
||||||
|
|
||||||
|
|
||||||
|
def _routing_client(seen: dict) -> HostBackendClient:
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
seen["method"] = req.method
|
||||||
|
seen["url"] = str(req.url)
|
||||||
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
|
||||||
|
c = HostBackendClient(
|
||||||
|
"https://api.example.com/prefix", "key", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
return c
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("call", "expected_body"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.create_deployment(
|
||||||
|
name="my-deploy",
|
||||||
|
source="internal_docker",
|
||||||
|
source_config={"deployment_type": "dev"},
|
||||||
|
source_revision_config={},
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"name": "my-deploy",
|
||||||
|
"source": "internal_docker",
|
||||||
|
"source_config": {"deployment_type": "dev"},
|
||||||
|
"source_revision_config": {},
|
||||||
|
},
|
||||||
|
id="internal_docker_create_omits_secrets_key_when_not_given",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.create_deployment(
|
||||||
|
name="my-deploy",
|
||||||
|
source="internal_docker",
|
||||||
|
source_config={"deployment_type": "prod"},
|
||||||
|
source_revision_config={},
|
||||||
|
secrets=[{"name": "KEY", "value": "val"}],
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"name": "my-deploy",
|
||||||
|
"source": "internal_docker",
|
||||||
|
"source_config": {"deployment_type": "prod"},
|
||||||
|
"source_revision_config": {},
|
||||||
|
"secrets": [{"name": "KEY", "value": "val"}],
|
||||||
|
},
|
||||||
|
id="internal_docker_create_forwards_secrets",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.update_deployment(
|
||||||
|
"dep-123",
|
||||||
|
"registry.example.com/app@sha256:abc",
|
||||||
|
revision_source="internal_docker",
|
||||||
|
secrets=[{"name": "KEY", "value": "val"}],
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"revision_source": "internal_docker",
|
||||||
|
"source_revision_config": {
|
||||||
|
"image_uri": "registry.example.com/app@sha256:abc"
|
||||||
|
},
|
||||||
|
"secrets": [{"name": "KEY", "value": "val"}],
|
||||||
|
},
|
||||||
|
id="internal_docker_revision_names_its_source",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.update_deployment_internal_source(
|
||||||
|
"dep-123",
|
||||||
|
source_tarball_path="tarballs/src.tgz",
|
||||||
|
config_path="langgraph.json",
|
||||||
|
secrets=[],
|
||||||
|
install_command="yarn install",
|
||||||
|
build_command="yarn build",
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"revision_source": "internal_source",
|
||||||
|
"source_revision_config": {
|
||||||
|
"source_tarball_path": "tarballs/src.tgz",
|
||||||
|
"langgraph_config_path": "langgraph.json",
|
||||||
|
},
|
||||||
|
"source_config": {
|
||||||
|
"install_command": "yarn install",
|
||||||
|
"build_command": "yarn build",
|
||||||
|
},
|
||||||
|
"secrets": [],
|
||||||
|
},
|
||||||
|
id="internal_source_revision_sends_js_build_commands",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.update_deployment_internal_source(
|
||||||
|
"dep-123",
|
||||||
|
source_tarball_path="tarballs/src.tgz",
|
||||||
|
config_path="langgraph.json",
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"revision_source": "internal_source",
|
||||||
|
"source_revision_config": {
|
||||||
|
"source_tarball_path": "tarballs/src.tgz",
|
||||||
|
"langgraph_config_path": "langgraph.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
id="internal_source_revision_omits_source_config_without_commands",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.create_deployment(
|
||||||
|
name="agent",
|
||||||
|
source="external_docker",
|
||||||
|
source_config={"resource_spec": {}},
|
||||||
|
source_revision_config={
|
||||||
|
"image_uri": "registry.example.com/agent@sha256:1"
|
||||||
|
},
|
||||||
|
secrets=[],
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"name": "agent",
|
||||||
|
"source": "external_docker",
|
||||||
|
"source_config": {"resource_spec": {}},
|
||||||
|
"source_revision_config": {
|
||||||
|
"image_uri": "registry.example.com/agent@sha256:1"
|
||||||
|
},
|
||||||
|
"secrets": [],
|
||||||
|
},
|
||||||
|
id="create_sends_the_source_configs_as_given",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.update_deployment(
|
||||||
|
"dep-1", "registry.example.com/agent@sha256:2", revision_source=None
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"source_revision_config": {
|
||||||
|
"image_uri": "registry.example.com/agent@sha256:2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
id="revision_without_source_override_omits_revision_source",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.update_deployment(
|
||||||
|
"dep-1",
|
||||||
|
"registry.example.com/agent@sha256:2",
|
||||||
|
revision_source="internal_docker",
|
||||||
|
tracked_packages=["langgraph:1.0.0"],
|
||||||
|
),
|
||||||
|
{
|
||||||
|
"revision_source": "internal_docker",
|
||||||
|
"source_revision_config": {
|
||||||
|
"image_uri": "registry.example.com/agent@sha256:2"
|
||||||
|
},
|
||||||
|
"tracked_packages": ["langgraph:1.0.0"],
|
||||||
|
},
|
||||||
|
id="revision_with_source_override_names_it",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_request_body_matches_control_plane_contract(call, expected_body):
|
||||||
|
captured: dict = {}
|
||||||
|
call(_capturing_client(captured))
|
||||||
|
assert json.loads(captured["body"]) == expected_body
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("call", "method", "route"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.create_deployment(
|
||||||
|
name="n",
|
||||||
|
source="internal_docker",
|
||||||
|
source_config={"deployment_type": "dev"},
|
||||||
|
source_revision_config={},
|
||||||
|
),
|
||||||
|
"POST",
|
||||||
|
"/v2/deployments",
|
||||||
|
id="create_deployment",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.get_deployment("dep-1"),
|
||||||
|
"GET",
|
||||||
|
"/v2/deployments/dep-1",
|
||||||
|
id="get_deployment",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.delete_deployment("dep-1"),
|
||||||
|
"DELETE",
|
||||||
|
"/v2/deployments/dep-1",
|
||||||
|
id="delete_deployment",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.update_deployment("dep-1", "img", revision_source=None),
|
||||||
|
"PATCH",
|
||||||
|
"/v2/deployments/dep-1",
|
||||||
|
id="patch_deployment",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.request_push_token("dep-1"),
|
||||||
|
"POST",
|
||||||
|
"/v2/deployments/dep-1/push-token",
|
||||||
|
id="push_token",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.request_upload_url("dep-1"),
|
||||||
|
"POST",
|
||||||
|
"/v2/deployments/dep-1/upload-url",
|
||||||
|
id="upload_url",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.list_revisions("dep-1", limit=5),
|
||||||
|
"GET",
|
||||||
|
"/v2/deployments/dep-1/revisions?limit=5",
|
||||||
|
id="list_revisions_puts_limit_in_query",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.get_revision("dep-1", "rev-2"),
|
||||||
|
"GET",
|
||||||
|
"/v2/deployments/dep-1/revisions/rev-2",
|
||||||
|
id="get_revision",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.get_build_logs("dep-1", "rev-2", {"limit": 10}),
|
||||||
|
"POST",
|
||||||
|
"/v1/projects/dep-1/revisions/rev-2/build_logs",
|
||||||
|
id="build_logs",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_request_targets_control_plane_route_under_base_url(call, method, route):
|
||||||
|
seen: dict = {}
|
||||||
|
call(_routing_client(seen))
|
||||||
|
assert (seen["method"], seen["url"]) == (
|
||||||
|
method,
|
||||||
|
f"https://api.example.com/prefix{route}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_injected_transport_receives_requests_under_the_prefixed_base_url():
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
seen["url"] = str(req.url)
|
||||||
|
seen["api_key"] = req.headers["x-api-key"]
|
||||||
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
|
||||||
|
c = HostBackendClient(
|
||||||
|
"https://smith.example.com/api-host",
|
||||||
|
"key",
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert c.list_revisions("dep-1", limit=2) == []
|
||||||
|
assert seen == {
|
||||||
|
"url": "https://smith.example.com/api-host/v2/deployments/dep-1/revisions?limit=2",
|
||||||
|
"api_key": "key",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
CLOUD = ("https://api.host.langchain.com", "https://smith.langchain.com")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("host_url", "langsmith_endpoint", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(None, None, CLOUD, id="nothing_configured_targets_cloud"),
|
||||||
|
pytest.param(
|
||||||
|
None, "https://api.smith.langchain.com", CLOUD, id="cloud_langsmith_api"
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
None,
|
||||||
|
"https://api.smith.langchain.com/api/v1",
|
||||||
|
CLOUD,
|
||||||
|
id="cloud_langsmith_api_with_versioned_path",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
None, "https://api.langchain.com", CLOUD, id="cloud_langchain_api_alias"
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
None,
|
||||||
|
"https://xapi.smith.langchain.com",
|
||||||
|
CLOUD,
|
||||||
|
id="lookalike_cloud_host_is_not_rewritten_into_a_control_plane",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
None,
|
||||||
|
"https://eu.api.smith.langchain.com",
|
||||||
|
("https://eu.api.host.langchain.com", "https://eu.smith.langchain.com"),
|
||||||
|
id="eu_cloud_maps_to_eu_control_plane",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
None,
|
||||||
|
"https://dev.api.smith.langchain.com",
|
||||||
|
("https://dev.api.host.langchain.com", "https://dev.smith.langchain.com"),
|
||||||
|
id="dev_cloud_maps_to_dev_control_plane",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
None,
|
||||||
|
"https://aks.smith.langchain.dev/api",
|
||||||
|
(
|
||||||
|
"https://aks.smith.langchain.dev/api-host",
|
||||||
|
"https://aks.smith.langchain.dev",
|
||||||
|
),
|
||||||
|
id="self_hosted_api_path_becomes_api_host",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
None,
|
||||||
|
"https://smith.example.com/api/v1",
|
||||||
|
("https://smith.example.com/api-host", "https://smith.example.com"),
|
||||||
|
id="self_hosted_versioned_api_path_becomes_api_host",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
None,
|
||||||
|
"https://smith.example.com",
|
||||||
|
("https://smith.example.com/api-host", "https://smith.example.com"),
|
||||||
|
id="self_hosted_origin_gets_api_host_appended",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
None,
|
||||||
|
"https://corp.example.com/langsmith/api/v1",
|
||||||
|
(
|
||||||
|
"https://corp.example.com/langsmith/api-host",
|
||||||
|
"https://corp.example.com/langsmith",
|
||||||
|
),
|
||||||
|
id="self_hosted_path_prefix_is_kept",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"https://custom.host.example",
|
||||||
|
"https://aks.smith.langchain.dev/api",
|
||||||
|
("https://custom.host.example", "https://smith.langchain.com"),
|
||||||
|
id="explicit_host_url_beats_langsmith_endpoint",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"https://api.host.langchain.com",
|
||||||
|
"https://aks.smith.langchain.dev/api",
|
||||||
|
CLOUD,
|
||||||
|
id="explicit_cloud_host_url_beats_self_hosted_endpoint",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"https://smith.example.com/api-host/",
|
||||||
|
None,
|
||||||
|
("https://smith.example.com/api-host", "https://smith.example.com"),
|
||||||
|
id="explicit_api_host_url_derives_dashboard_root",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"https://corp.example.com/langsmith/api-host",
|
||||||
|
None,
|
||||||
|
(
|
||||||
|
"https://corp.example.com/langsmith/api-host",
|
||||||
|
"https://corp.example.com/langsmith",
|
||||||
|
),
|
||||||
|
id="explicit_api_host_url_keeps_path_prefix_in_dashboard",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"http://localhost:8080",
|
||||||
|
None,
|
||||||
|
("http://localhost:8080", "http://localhost:8080"),
|
||||||
|
id="localhost_dashboard_is_the_same_origin",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"http://localhost:8080/api-host",
|
||||||
|
None,
|
||||||
|
("http://localhost:8080/api-host", "http://localhost:8080"),
|
||||||
|
id="localhost_api_host_dashboard_is_the_origin",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"https://eu.api.host.langchain.com",
|
||||||
|
None,
|
||||||
|
("https://eu.api.host.langchain.com", "https://eu.smith.langchain.com"),
|
||||||
|
id="regional_control_plane_maps_to_regional_dashboard",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_control_plane_endpoints_resolve(host_url, langsmith_endpoint, expected):
|
||||||
|
endpoints = ControlPlaneEndpoints.resolve(host_url, langsmith_endpoint)
|
||||||
|
|
||||||
|
assert (endpoints.control_plane_url, endpoints.dashboard_url) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("payload", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
{"resources": [{"id": "a"}, {"id": "b"}]},
|
||||||
|
[{"id": "a"}, {"id": "b"}],
|
||||||
|
id="list_returns_the_resources",
|
||||||
|
),
|
||||||
|
pytest.param({"resources": []}, [], id="empty_list"),
|
||||||
|
pytest.param({}, [], id="missing_key"),
|
||||||
|
pytest.param({"resources": None}, [], id="null_resources"),
|
||||||
|
pytest.param(
|
||||||
|
{"resources": ["nope", {"id": "a"}]}, [{"id": "a"}], id="skips_non_objects"
|
||||||
|
),
|
||||||
|
pytest.param([], [], id="unexpected_envelope"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_list_endpoints_return_resource_objects(payload, expected):
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(200, json=payload)
|
||||||
|
|
||||||
|
c = HostBackendClient(
|
||||||
|
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert c.list_deployments() == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_listeners_asks_for_a_full_page():
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
seen["url"] = str(req.url)
|
||||||
|
return httpx.Response(200, json={"resources": [{"id": "listener-1"}]})
|
||||||
|
|
||||||
|
c = HostBackendClient(
|
||||||
|
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert c.list_listeners() == [{"id": "listener-1"}]
|
||||||
|
assert seen["url"] == "https://api.example.com/v2/listeners?limit=100"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("control_plane_url", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("https://api.host.langchain.com", True, id="cloud"),
|
||||||
|
pytest.param("https://eu.api.host.langchain.com", True, id="cloud_region"),
|
||||||
|
pytest.param("https://dev.api.host.langchain.com", True, id="cloud_dev"),
|
||||||
|
pytest.param("https://smith.example.com/api-host", False, id="self_hosted"),
|
||||||
|
pytest.param(
|
||||||
|
"https://corp.example.com/langsmith/api-host",
|
||||||
|
False,
|
||||||
|
id="self_hosted_prefix",
|
||||||
|
),
|
||||||
|
pytest.param("http://localhost:8080/api-host", False, id="local"),
|
||||||
|
pytest.param(
|
||||||
|
"https://evil-api.host.langchain.com", False, id="lookalike_needs_a_dot"
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_is_cloud_recognises_the_managed_control_plane(control_plane_url, expected):
|
||||||
|
endpoints = ControlPlaneEndpoints.from_control_plane_url(control_plane_url)
|
||||||
|
|
||||||
|
assert endpoints.is_cloud is expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("call", "expected_params"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.list_deployments(name="agent"),
|
||||||
|
{"name": "agent"},
|
||||||
|
id="exact_name_filters_server_side",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.list_deployments(name_contains="age"),
|
||||||
|
{"name_contains": "age"},
|
||||||
|
id="substring_search_keeps_its_own_parameter",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.list_deployments(),
|
||||||
|
{},
|
||||||
|
id="no_filter_sends_no_parameters",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
lambda c: c.list_deployments(
|
||||||
|
name="agent", name_contains="agent", limit=100
|
||||||
|
),
|
||||||
|
{"name": "agent", "name_contains": "agent", "limit": "100"},
|
||||||
|
id="both_filters_travel_together_for_older_servers",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_list_deployments_sends_one_name_filter(call, expected_params):
|
||||||
|
seen: dict = {}
|
||||||
|
|
||||||
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
|
seen.update(dict(req.url.params))
|
||||||
|
return httpx.Response(200, json={"resources": []})
|
||||||
|
|
||||||
|
call(
|
||||||
|
HostBackendClient(
|
||||||
|
"https://api.example.com", "key", transport=httpx.MockTransport(handler)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert seen == expected_params
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("body", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
{"detail": "Source configuration error: bad listener"},
|
||||||
|
"Source configuration error: bad listener",
|
||||||
|
id="fastapi_detail_is_unwrapped",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
{"detail": {"loc": ["body"], "msg": "nope"}},
|
||||||
|
None,
|
||||||
|
id="a_structured_detail_is_left_alone",
|
||||||
|
),
|
||||||
|
pytest.param({"other": "shape"}, None, id="an_unknown_shape_is_left_alone"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_error_detail_is_readable(body, expected):
|
||||||
|
c = HostBackendClient(
|
||||||
|
"https://api.example.com",
|
||||||
|
"key",
|
||||||
|
transport=httpx.MockTransport(lambda req: httpx.Response(400, json=body)),
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HostBackendError) as error:
|
||||||
|
c.get_deployment("dep-1")
|
||||||
|
|
||||||
|
assert error.value.detail == expected
|
||||||
|
if expected is not None:
|
||||||
|
assert error.value.message.endswith(expected)
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from langgraph_cli.image_reference import ImageReference
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("reference", "repository", "tag"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
"registry.example.com/team/app:v1",
|
||||||
|
"registry.example.com/team/app",
|
||||||
|
"v1",
|
||||||
|
id="tag_after_last_slash",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"registry.example.com/team/app",
|
||||||
|
"registry.example.com/team/app",
|
||||||
|
None,
|
||||||
|
id="no_tag",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"localhost:5000/app",
|
||||||
|
"localhost:5000/app",
|
||||||
|
None,
|
||||||
|
id="registry_port_is_not_a_tag",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"localhost:5000/app:latest",
|
||||||
|
"localhost:5000/app",
|
||||||
|
"latest",
|
||||||
|
id="registry_port_with_tag",
|
||||||
|
),
|
||||||
|
pytest.param("app:dev", "app", "dev", id="bare_name_with_tag"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_parse_splits_repository_and_tag(reference, repository, tag):
|
||||||
|
assert ImageReference.parse(reference) == ImageReference(repository, tag)
|
||||||
|
|
||||||
|
|
||||||
|
def test_with_tag_replaces_the_tag():
|
||||||
|
assert ImageReference("r/app", "v1").with_tag("v2") == ImageReference("r/app", "v2")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("reference", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(ImageReference("r/app", "v1"), "r/app:v1", id="tagged"),
|
||||||
|
pytest.param(ImageReference("r/app"), "r/app", id="untagged"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_str_renders_the_docker_reference(reference, expected):
|
||||||
|
assert str(reference) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("repo_digest", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("localhost:5000/app@sha256:abc", True, id="same_repository"),
|
||||||
|
pytest.param("localhost:5000/app-2@sha256:abc", False, id="other_repository"),
|
||||||
|
pytest.param("mirror.example.com/app@sha256:abc", False, id="other_registry"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_matches_digest_only_for_the_same_repository(repo_digest, expected):
|
||||||
|
assert ImageReference("localhost:5000/app", "v1").matches_digest(repo_digest) is (
|
||||||
|
expected
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_rejects_a_digest_reference():
|
||||||
|
with pytest.raises(ValueError, match="digest"):
|
||||||
|
ImageReference.parse("registry.example.com/app@sha256:abc")
|
||||||
Generated
+121
-18
@@ -39,15 +39,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.13.0"
|
version = "4.14.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -217,9 +217,10 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain-core"
|
name = "langchain-core"
|
||||||
version = "1.3.3"
|
version = "1.6.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "jsonpatch" },
|
{ name = "jsonpatch" },
|
||||||
{ name = "langchain-protocol" },
|
{ name = "langchain-protocol" },
|
||||||
{ name = "langsmith" },
|
{ name = "langsmith" },
|
||||||
@@ -230,9 +231,9 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "uuid-utils" },
|
{ 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/89/7b/406b4a8dd43dd01f69e04aafdee206809a2097134b2f944e12802eae7948/langchain_core-1.6.2.tar.gz", hash = "sha256:1ec6d3a98f7c8cdbb5bb0deff86e7ca37e16bf4f8f49f022d10723656c62352d", size = 1004390, upload-time = "2026-09-04T21:29:22.769Z" }
|
||||||
wheels = [
|
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/a8/62/d3fb7c215cb2f237c3fe84880bf347a38deafef6033b6d5f1339ba8ca401/langchain_core-1.6.2-py3-none-any.whl", hash = "sha256:21e6c7cf097c68b777fd69ea00864f09bd9ca76ac73e3e3fa14e1ee366eb4711", size = 571690, upload-time = "2026-09-04T21:29:21.175Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -249,7 +250,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph"
|
name = "langgraph"
|
||||||
version = "1.1.6"
|
version = "1.2.11"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -259,9 +260,9 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "xxhash" },
|
{ 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/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" }
|
||||||
wheels = [
|
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/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -294,28 +295,31 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-prebuilt"
|
name = "langgraph-prebuilt"
|
||||||
version = "1.0.9"
|
version = "1.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
{ name = "langgraph-checkpoint" },
|
{ 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 = [
|
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]]
|
[[package]]
|
||||||
name = "langgraph-sdk"
|
name = "langgraph-sdk"
|
||||||
version = "0.3.13"
|
version = "0.4.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "langchain-core" },
|
||||||
|
{ name = "langchain-protocol" },
|
||||||
{ name = "orjson" },
|
{ name = "orjson" },
|
||||||
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
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/3f/ae/91446c1fffa04a2dc1f81afbfb5bfff3590452891a72bd9098a656ab2657/langgraph_sdk-0.4.4.tar.gz", hash = "sha256:4e651ffa09de695681579396375377bdde23bedbc8e35b070e615cbd5af7da8b", size = 345789, upload-time = "2026-08-27T21:25:22.593Z" }
|
||||||
wheels = [
|
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/43/d5/2ad7f3a835c6fcebf58b6669552536ecd52d2dfe4cf49c6c36648fd83572/langgraph_sdk-0.4.4-py3-none-any.whl", hash = "sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64", size = 162270, upload-time = "2026-08-27T21:25:21.072Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -728,11 +732,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -781,6 +785,105 @@ requires-dist = [
|
|||||||
{ name = "langgraph-checkpoint-postgres", specifier = ">=3.0.0" },
|
{ name = "langgraph-checkpoint-postgres", specifier = ">=3.0.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "websockets"
|
||||||
|
version = "16.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xxhash"
|
name = "xxhash"
|
||||||
version = "3.6.0"
|
version = "3.6.0"
|
||||||
|
|||||||
Generated
+118
-15
@@ -13,15 +13,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.13.0"
|
version = "4.14.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -191,9 +191,10 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain-core"
|
name = "langchain-core"
|
||||||
version = "1.4.0"
|
version = "1.6.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "httpx" },
|
||||||
{ name = "jsonpatch" },
|
{ name = "jsonpatch" },
|
||||||
{ name = "langchain-protocol" },
|
{ name = "langchain-protocol" },
|
||||||
{ name = "langsmith" },
|
{ name = "langsmith" },
|
||||||
@@ -204,9 +205,9 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "uuid-utils" },
|
{ name = "uuid-utils" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/59/de/679a53472c25860837e32c0442c962fa86e95317a36460e2c9d5c91b17c2/langchain_core-1.4.0.tar.gz", hash = "sha256:1dc341eed802ed9c117c0df3923c991e5e9e226571e5725c194eeb5bd93d1a7f", size = 920260, upload-time = "2026-05-11T18:42:35.919Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/89/7b/406b4a8dd43dd01f69e04aafdee206809a2097134b2f944e12802eae7948/langchain_core-1.6.2.tar.gz", hash = "sha256:1ec6d3a98f7c8cdbb5bb0deff86e7ca37e16bf4f8f49f022d10723656c62352d", size = 1004390, upload-time = "2026-09-04T21:29:22.769Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/0f/1a/86c38c27b81913a1c6c12448cab55defb5a1097c7dc9a4cea83f55477a2d/langchain_core-1.4.0-py3-none-any.whl", hash = "sha256:23cbbdb46e38ddd1dd5247e6167e96013eae74bea4c5949c550809970a9e565c", size = 548120, upload-time = "2026-05-11T18:42:33.992Z" },
|
{ url = "https://files.pythonhosted.org/packages/a8/62/d3fb7c215cb2f237c3fe84880bf347a38deafef6033b6d5f1339ba8ca401/langchain_core-1.6.2-py3-none-any.whl", hash = "sha256:21e6c7cf097c68b777fd69ea00864f09bd9ca76ac73e3e3fa14e1ee366eb4711", size = 571690, upload-time = "2026-09-04T21:29:21.175Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -223,7 +224,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph"
|
name = "langgraph"
|
||||||
version = "1.2.2"
|
version = "1.2.11"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -233,9 +234,9 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "xxhash" },
|
{ name = "xxhash" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/e6/5a/ffc12434ee8aecab830d58b4d204ddea45073eae7639c963310f671a5bf5/langgraph-1.2.2.tar.gz", hash = "sha256:f54a98458976b3ff0774683867df125fb52d8dbedeb2441d0b0656a51331cee5", size = 695730, upload-time = "2026-05-26T18:07:28.49Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/56/0d/c8e7ee98896659e1b6555db0ab115a9ca899844744645d5d894032bab1d7/langgraph-1.2.11.tar.gz", hash = "sha256:9ecfe11e50d338b34b15cf4d8a442642de103e8ae6971320efba84e4542eb363", size = 725753, upload-time = "2026-08-11T14:00:36.945Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/42/9b/b08d578bba73e25351152dfd3d6d21e81210a5fff1b6f26e56f33197c8f5/langgraph-1.2.2-py3-none-any.whl", hash = "sha256:0a851bf4ba5939c5474a2fd57e6b439b5315283e254e42943bd392c2d71a5e03", size = 236376, upload-time = "2026-05-26T18:07:26.577Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/7f/c5c30e4be99ff821029c7ac872a480676bb179c9f3df85ea3f38d13f86d4/langgraph-1.2.11-py3-none-any.whl", hash = "sha256:8bab70de7b2d00b5300fb289bcf38d8b241400f3184c1e95e8ce706fb0e8686b", size = 248854, upload-time = "2026-08-11T14:00:35.494Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -266,15 +267,18 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-sdk"
|
name = "langgraph-sdk"
|
||||||
version = "0.3.15"
|
version = "0.4.4"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
|
{ name = "langchain-core" },
|
||||||
|
{ name = "langchain-protocol" },
|
||||||
{ name = "orjson" },
|
{ name = "orjson" },
|
||||||
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
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" }
|
sdist = { url = "https://files.pythonhosted.org/packages/3f/ae/91446c1fffa04a2dc1f81afbfb5bfff3590452891a72bd9098a656ab2657/langgraph_sdk-0.4.4.tar.gz", hash = "sha256:4e651ffa09de695681579396375377bdde23bedbc8e35b070e615cbd5af7da8b", size = 345789, upload-time = "2026-08-27T21:25:22.593Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/be/a5/0196d9c05749c25bc198e4909d68c998bc3120297e14944921baf2f4c384/langgraph_sdk-0.3.15-py3-none-any.whl", hash = "sha256:3838773acf7456d158165385d49f48f1e856f28b56ccd99ea139a8f27004815d", size = 98166, upload-time = "2026-05-22T16:54:26.013Z" },
|
{ url = "https://files.pythonhosted.org/packages/43/d5/2ad7f3a835c6fcebf58b6669552536ecd52d2dfe4cf49c6c36648fd83572/langgraph_sdk-0.4.4-py3-none-any.whl", hash = "sha256:39afe416c91742925e6f8a93715f566d499b36e1b636b804a4ffe3190e4f4e64", size = 162270, upload-time = "2026-08-27T21:25:21.072Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -668,11 +672,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -775,6 +779,105 @@ wheels = [
|
|||||||
{ 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" },
|
{ 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 = "websockets"
|
||||||
|
version = "16.1.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/21/f7/bc3a25c5ec26ce62ce487690becc2f3710bbc7b33338f005ad390db0b986/websockets-16.1.1.tar.gz", hash = "sha256:db234eda965dcce15df96bb9709f587cd87d4d52aaf0e80e2f34ec04c7670c57", size = 182204, upload-time = "2026-07-17T22:51:05.858Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2b/03/47debfe28e9d6d354be5d777b67fd44c359b9eb299a5d103500bd7cc3e37/websockets-16.1.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:d0fcf657e9f13ff4b177960ab2200237b12994232dfb6df16f1cfe1d4339f93c", size = 179566, upload-time = "2026-07-17T22:48:49.596Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/72/93/31efa1ed78c17e5cfc229fd449e3966e1b9cc15753204cd585cc8dd01f4a/websockets-16.1.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b852788aa51764e2d8e4cf5493d559326bcae5e38d16ba25ffa322b034df272a", size = 177250, upload-time = "2026-07-17T22:48:50.942Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/4a/542378ab3972b0c1cf1df3df3eff9591cea0d30c58c3aa3c4ddbc244e787/websockets-16.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1427fb4cf0d72f66333e2cacc3ff5f575bf2d7008166ce991a4a470b21d51a22", size = 177528, upload-time = "2026-07-17T22:48:52.59Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/d9/162321f63c7eed558e9e1798ed7a1e34a4f6dab51f35419e4ed7a4907979/websockets-16.1.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:da4ca1a9d72f9030b3146b8d7022719a9f3d478f61efe6f7dd51d243f61c51b2", size = 186859, upload-time = "2026-07-17T22:48:53.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/de/09/87df740f7430ce564bd52402e9c9458d4d0459cc7d2ee29e530c8204851b/websockets-16.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d7f0f8bdb25d2c632b72527325e4776430fd5bc61b9118de4e2b8ddb5f5b01", size = 188095, upload-time = "2026-07-17T22:48:55.384Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d2/12/3d2703af7cc095f3c81904c92208cc1ae79affbc67376944b50ee9301f73/websockets-16.1.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dfcad78ea1492ee3a9ec765cb7f51bbc17d477107aaf6b22abf7b2558d1c5a0", size = 191385, upload-time = "2026-07-17T22:48:56.742Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/69/986aa0234a964a00f5149cfc46e136e96c8faad1c783474550f40d31aef4/websockets-16.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fb9a0a6dc3d1b3986cb88091b6899f0396651e0f74e2c9766ab8d6ffc3842e29", size = 188653, upload-time = "2026-07-17T22:48:58.134Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/6b/10f9d03e3970a69ba67bd3b46b87a929b586d0300fadbfe14f57c1f85490/websockets-16.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29dfa8114c4a620c69591c5973860f768eac29d3fd6904f37f34266cb219c512", size = 187426, upload-time = "2026-07-17T22:48:59.515Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/56/db/bb3aad62bf63d8bb3f0634b2eabffcfb3677a34bd19492110ff6869cf703/websockets-16.1.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ff9417c0ada4d0f7d212f928303e5579bdf3ace4c802fa4afabb30995da58c3", size = 184882, upload-time = "2026-07-17T22:49:00.916Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/4c/c09a2ea9bfbeccce52fdc383e5f28af4bc8843338aabac28c81489af6120/websockets-16.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fe0b50da2d84535fb4f7b4bfa951280f97ce3d558a0443b541166d609e67b57", size = 187584, upload-time = "2026-07-17T22:49:02.283Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/8b/31bb4eb4d9eaacf1fdd39d115772a8aeaedfc19b5dc262e57ffbc8a9d42c/websockets-16.1.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:34420aaa64440ebd51ac72ca8a45ef4626429438c9b02e633ae412ed43f925d3", size = 186174, upload-time = "2026-07-17T22:49:03.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2f/e4/dc02d725610a1ad49e193ef91a548194d71bdc6cdf27da83067dd1f73995/websockets-16.1.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a6a61aff018180c9c50b7b0da33bfd29d378af3497429c95006c589a23a11648", size = 187986, upload-time = "2026-07-17T22:49:05.553Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e0/73/30ed84c8bfd14c73d4af29d5ed9323c3073b48e0b7b23b67070f4e7fd59b/websockets-16.1.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:04fd29a0e2fe9414a95b00e92c67ae51bf900c50c0f8a4b2dafdad621f49ea1d", size = 185565, upload-time = "2026-07-17T22:49:06.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/d3/4be8d4959f51e31b4f8fc0ece12b45bd3b6c0d15ea23b9990d9c11fc805f/websockets-16.1.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5c31aa7e39ee3e8a358573257f1c0bb5c52430d1b637030dd9c8cc2c282926be", size = 186598, upload-time = "2026-07-17T22:49:08.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/fa/abb38597a52d84ed9cfacadc7a0c6f2db282c0ab23cdf72b58a666a21227/websockets-16.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d14bfb217eb4701e850f1525c9d29d79c44794cdf1c299ead25f39f8c78dea81", size = 186834, upload-time = "2026-07-17T22:49:09.766Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/80/1119ad08a228b90c4eb77fbe48df7836731a605f5f881ba701ca826a4a65/websockets-16.1.1-cp311-cp311-win32.whl", hash = "sha256:2e28e602bb13da44fbe518c1781a88e3b9d4c3d48d02c9bad83e546164336f57", size = 179940, upload-time = "2026-07-17T22:49:11.196Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/b2/e511c1c6f64a95c2f3fc54bffda0e14eaa7e9442be605c29270f7589b918/websockets-16.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:7421fad442de870a8cbf2287d1cad7e706ece0dbfeba5e911df132cbdc1cb56a", size = 180239, upload-time = "2026-07-17T22:49:12.519Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/9d/681cda21c9eee743203a6cb79b9d3d05adad9aa60ec660c6c9bf4dd619ca/websockets-16.1.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:cc97814dfb786a83b6e2dc2e79351e1b83e6d715647d6887fcabd83026417a00", size = 179600, upload-time = "2026-07-17T22:49:13.92Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fb/8d/6195a88b45e8d2a8f745fc2046e36f885a3c9763e6767d2c46229bf9510c/websockets-16.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e047dc87ef7ca50f4d309bf775ad4a71711c58556d75d7bd0604b2317f43e94b", size = 177272, upload-time = "2026-07-17T22:49:15.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/e3/fe2d498c64dea0095c9a9f9a351af4cd6eef31b618395582bc1f38ba45ff/websockets-16.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:01fbdcbac298efe19360b94bc0039c8f746f0220ba570f327577bfee81059175", size = 177542, upload-time = "2026-07-17T22:49:16.875Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fe/ed/f1831681fce0e3242346e5458486003c5f124ed69e5e0b847fd029db4973/websockets-16.1.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0f62863e8a00a6d33c3d6566ec0b89f23787b747ffe0c3bc71ec0e76b82c94b1", size = 187137, upload-time = "2026-07-17T22:49:18.323Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6f/79/4ff9dcc1bb46f6b4c536936dde1fd60f9b564f3304307274db97f4c9496d/websockets-16.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8087e82f842609734c9b5a1330464f8e94e346ba0e18c832c08bafa4b0d63c15", size = 188374, upload-time = "2026-07-17T22:49:19.65Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/c3/5c49b6efb36cab733d23773f6de575e1dba65736ead17d5d2b2a1daef779/websockets-16.1.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2bb5d041a8307d2e18782e7ce777f6fdb1e8c2f5d09291484b18c294b789d9aa", size = 191155, upload-time = "2026-07-17T22:49:21.331Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6e/f6/56ccceda3a4838d18f1d40821480da4775397e8b1eecf4031e20c50e2e90/websockets-16.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1db4de4a0e95673f7545d393c49eeb0c2f18ac1ef93073218c79d5cdb2ee75ab", size = 189011, upload-time = "2026-07-17T22:49:22.889Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/86/d6/ad5286241a2bce1107e2798d3bfbd62cf79aee167bdb654f8cb1e9dbf949/websockets-16.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f17dbe07eb3ea7f99e4df9b7e0efefe80fbf30d37a8cc4d561a0aed310bc8847", size = 187766, upload-time = "2026-07-17T22:49:24.339Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bc/67/d65c970b7e347fdca69479beb7811c2060529956730a7a4e3ae7c66b0e31/websockets-16.1.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4b57693728576d84ede0a77987ab16881b783d2cd9f1dc180a8fbbc3f79c4428", size = 185173, upload-time = "2026-07-17T22:49:25.743Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1d/5b/14af3cd4ee69d8ea9baca58f3dc3cfb1ba78332a347fd478cb096549d60e/websockets-16.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2a636ff1e7a5c4edf71ef0e79adae7f25dba93b4fcbe3dc958733477ffeb0eaf", size = 187809, upload-time = "2026-07-17T22:49:27.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/11/be301710d70de97e3e7b3586e6d492c9c06d6a61bf1c2202c36cf0c75607/websockets-16.1.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:d6bec75c290fe484a8ba4cacdf838501e17c06ecfbbf31eede81a9e431bd7751", size = 186412, upload-time = "2026-07-17T22:49:28.611Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/07/fe1435bf6fe738a3d3b54dbe0c18dabf12cba4d909ac8b58b539ce27c1f4/websockets-16.1.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:54509b8e92fee4453e152b7558ddef37ce9705a044922f2095a6105e3f80c96f", size = 188290, upload-time = "2026-07-17T22:49:29.965Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8a/0a/81f394aff8efcbb01208c1ced77df0a3c7fcce584a88c7273663697946c2/websockets-16.1.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f0aa4aad3b1b69ad3fd85a0fd0952ec64331c762bd77ec51cc814170873890b2", size = 185844, upload-time = "2026-07-17T22:49:31.447Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/5c/dd485b995473f415510251fe9bd708f2d24458f439fce958daf8d66dc7c6/websockets-16.1.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:42290eb6db4ccaca7012656738214f8514082fb6fa40cdeb61bb9a471b52e383", size = 186823, upload-time = "2026-07-17T22:49:33.104Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/0b/f78de76ff446f1e66af12b43c48a35f31744de93cfdec2f4ea67d5d7bbf1/websockets-16.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:53260c8930da5771cec89439bff99c20c8cb03ddb9588b980697355a83cd4bd3", size = 187102, upload-time = "2026-07-17T22:49:34.616Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/a1/4cf892007778eaf84ad162bfc98046e0ed89b63ac55949e3236626b2a23f/websockets-16.1.1-cp312-cp312-win32.whl", hash = "sha256:1d27fa8462ad6a1cb36206a3d0640b2333340def181fae11ed7f9adeaa5c0747", size = 179943, upload-time = "2026-07-17T22:49:36.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d9/de/6abe251d28c3a3f217096575400b27750b18e0b1d2fff3a2a239960fea07/websockets-16.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:b436f6ec4fc3a6b4237c84d3f83170ed2b40bb584222f0ac47a0c8a5921980c7", size = 180243, upload-time = "2026-07-17T22:49:37.626Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/fd/6ec6c6d2850aea25b1b2aa9901a016980bb87d01e89b3eb00470b1b5d471/websockets-16.1.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ab59169ace05dcb49a1d4118f0bde139557adf45091bd85747e36bf5de984dd1", size = 179587, upload-time = "2026-07-17T22:49:38.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5f/d8/1d299d2dd34087db39831a34cc645ef8a6f89d78efada6983093513cd81c/websockets-16.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5e3b7d601f6f84156b08cc4a5e541c2b50ad7b36cfc302b657a12477c904a5df", size = 177272, upload-time = "2026-07-17T22:49:40.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/86/0a70d3ae2f0f2256bb41302d9804dbca65d4360281e7feb3e1f94102ac46/websockets-16.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cd2ca96a082a36964aca83e992f72abeb61b7306c1a6cba4c7d06a7b93750cac", size = 177530, upload-time = "2026-07-17T22:49:41.786Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/c2/c676c69444d9db448b3f0a55a98dcc534affce0bce961d9d2f0b8499b10a/websockets-16.1.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f5d497865f05bb222cab7016c6034542e84e5f29f49c6fd3f4939cda7197b5b8", size = 187197, upload-time = "2026-07-17T22:49:43.658Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0b/13/88137fbaf726ebe29d62c1117fa11fa2bbb6209dc79d4ad738efbe36a2aa/websockets-16.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bae954c382e013d5ea5b190d2830526bfa45ad121c326da0049b8c769f185db6", size = 188433, upload-time = "2026-07-17T22:49:45.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/6d/46c2f2ce6751cb26f39293e1ecbf8544cb01321397cd476c2756b98c216d/websockets-16.1.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e09f753a169951eb4f28c2c774f71069304f66e7277e0f5a2892423599cfa854", size = 189868, upload-time = "2026-07-17T22:49:46.581Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/29/2b/170a9e8097636cfde4dc3c592b6e00b18a44a2f5407606d96ca542dd5838/websockets-16.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:024193f8551a2b0eafbdd160911012c4e6c228c28430c84433253299a9e42d6a", size = 189059, upload-time = "2026-07-17T22:49:47.972Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/48/f0d4ebc9ab4b473b8861b9e20fdb663d515d42f7befdf62cdb60fee7a1ec/websockets-16.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aabe464bfd13bd25f4821faf111da6fefdc389f870265a53105580e45b0a2e49", size = 187814, upload-time = "2026-07-17T22:49:49.344Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/ba/39a41d3ae8e72696a9492581900611c5a91e2b07563b0bcd2523adea9854/websockets-16.1.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a28fcbc9b6baf54a2e23f8655f308e4ccc6afdd7266f8fe7954f320dcda0f785", size = 185229, upload-time = "2026-07-17T22:49:50.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3c/36/ac15b604f850d1907f0a85ed721cefe47cd45034b3620069b829746cccbe/websockets-16.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:79eace538c6a97e96d0d03d4f9d314f9677f5ed85a8a984992ffd90b13cb8a56", size = 187874, upload-time = "2026-07-17T22:49:52.228Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/f3/3fbd5d71d59299c3770faa5884d4f45070236ca5a35ab3a61830812c409a/websockets-16.1.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:496af849a472b531f758dbd4d61338f5000538cb1a7b3d20d9d32a264517f509", size = 186469, upload-time = "2026-07-17T22:49:53.776Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/fc/dd90349bba58af2a53ef2ddd9c32716c81eb6d59a0687939fff561860878/websockets-16.1.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5283810d2646741a0d8da2aa733d6aefa0545809afccb2a5d105a26bc45125f1", size = 188347, upload-time = "2026-07-17T22:49:55.202Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/f3/f73ba86427682da59b78c11d77ba56d5b801c32e84afe79b274bbd6a9bb2/websockets-16.1.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4e3b680b1e0a27457e727a0d572fd81dffa87b6dbf8b228ab57da64f7d85aead", size = 185903, upload-time = "2026-07-17T22:49:56.75Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/34/7c/f95eb20e80104173b3a0a092291f89ea4047ef6e608e0a57ca06eb14eecb/websockets-16.1.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:69159730a823dde3ea8d08783e8d47ef135a6d7e8d44eb127e32b321c9db8e3e", size = 186855, upload-time = "2026-07-17T22:49:58.467Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/35/dd875b3e050ff232d60fa377707f890e369f74d134f1be32e8f68879747c/websockets-16.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ed5bb271084b46530ee2ddc0410537a9961152c5ccba2fc98c5276d992ccba87", size = 187140, upload-time = "2026-07-17T22:50:00.016Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e8/dc/5cbfcb41824502f6af93b8f3943a4d06c67c23c7d2e31eb18748c4a5b2a7/websockets-16.1.1-cp313-cp313-win32.whl", hash = "sha256:cfb70b4eb56cac4da0a83588f3ad50d46beb0690391082f3d4e2d488c70b68ea", size = 179928, upload-time = "2026-07-17T22:50:01.685Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/c1/71e5deb5b7f8f226997ab64908c184ac3105c0155ce2d486f318e5dd08a8/websockets-16.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:d9531d9cbeac99af6f038fb1bc351403531f7d634a2c2e10e2f7c854c6ed5b68", size = 180242, upload-time = "2026-07-17T22:50:03.117Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/a2/ba78a164eeea4620df4a4df4bd2ed6017438c4655cc0f36f2c0bc0432355/websockets-16.1.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:443aefe96b7fdb132e2a70806cca1f2af49bb3f28e47abcd7c2e9dcf4d8fa1b8", size = 179635, upload-time = "2026-07-17T22:50:05.001Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/08/d26d7a7628cd4ac34cbbdb63ac80914ca842ed8e42938c40a53567806df3/websockets-16.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6456ff333092d509127d75a638cb411afae8ff17f092635015d1902efec8a293", size = 177320, upload-time = "2026-07-17T22:50:06.427Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/45/ebec83e6269536aa5932533c67b0af5c781f3e73fdbcd68672dcf43f4f44/websockets-16.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fce6c48559c86d1ac3632ccb1bebc7d5442fbe79bd9bb0e40379ee54be2a4051", size = 177544, upload-time = "2026-07-17T22:50:07.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c9/d5/abc614d2297f6c1c3e01e61260364457a47c25cc1cf6a879038902bc6aa8/websockets-16.1.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:92b820d345f7a3fc7b8163949ee92df910f290c3fc517b3d5301c78065adafe1", size = 187270, upload-time = "2026-07-17T22:50:09.275Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/52/71/4c99af3b87dff1b2927981f6876607d4acb45338c665242168d3982f7758/websockets-16.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a606d9c24035242a3e256e9d5b77ed9cd6bccfcb7cf993e5ca3c0f6f68fb6a7", size = 188509, upload-time = "2026-07-17T22:50:10.722Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/b4/5c8ca14b0df7eb84ed0524165c5359150210140817a3312aee57bf62a1cf/websockets-16.1.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:414e596c75f74e0994084694189d7dc9229fb278e33064d6784b73ffbba3ca31", size = 189882, upload-time = "2026-07-17T22:50:12.293Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/25/c1/bedfba9e70557129cb8083748d167bdcc01483dedf0f0df143676df05cbe/websockets-16.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:536676848fc5961aca9d20389951f59169508f765637a172403dc5434d722fa0", size = 189114, upload-time = "2026-07-17T22:50:13.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/09/aa835b2787835aebd839114be5de51b797cb480b63ba42b26d34dfe147cb/websockets-16.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:97fd3a0e8b53efa41970ac1dff3d8cf0d2884cadeb4caaf95db7ad1526926ee3", size = 187861, upload-time = "2026-07-17T22:50:15.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/26/f6408330694dbc9830857d9d23bc14ac4f6875127a480cfdda8d5ca21198/websockets-16.1.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7b1b19636af86a3c7995d4d028dbe376f39b4bf31541146f9c123582a6c94562", size = 185286, upload-time = "2026-07-17T22:50:16.741Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/9a/e0675e70dd8a80762cf35bb18799d3f290a4890ffe6439bc51d222796083/websockets-16.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:41c8e77f17294c0ac18008a7309b99b34ee72247ef10b6dff4c3f8b5ac29896b", size = 187935, upload-time = "2026-07-17T22:50:18.213Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/c1/3234cfb86afde01b81e9bddcc6e534c440975d60a13991259e833069ab3e/websockets-16.1.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:9f63bcef7f4b02b06b35fc01c93b96c43b5e88e1e8868676caacf493d5a31f3a", size = 186444, upload-time = "2026-07-17T22:50:19.67Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/87/9c15206e1d778923d8daa9657de07aa62ea815e13448319c98458c37b281/websockets-16.1.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dab9eb87869da2d6ed3af3f3adf28414baae6ec9d4df355ffc18889132f3436c", size = 188409, upload-time = "2026-07-17T22:50:21.28Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f2/00/cf5de5c67676de2d3eef8b2a518f168f6796595447a5b7161ba0d012915c/websockets-16.1.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:43e3a9fdd7cbf7ba6040c31fae0faf84ca1474fef777c4e37912f1540f854499", size = 185958, upload-time = "2026-07-17T22:50:22.719Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/c0/731b6ddede2e4136912ec4cff2cffbda35af73546be4762c3d7bd3bd79af/websockets-16.1.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:056ae37939ed7e9974f364f5864e76e49182622d8f9751ac1903c0d09b013985", size = 186911, upload-time = "2026-07-17T22:50:24.108Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8c/7f/39c634472c4469a24a7c09cecddffb08fac6d0e74f73881a94ee8a40a196/websockets-16.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a0eadbbf2c30f01efa58e1f110eb6fa293261f6b0b1aa38f7f48707107690af9", size = 187204, upload-time = "2026-07-17T22:50:25.548Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/26/89/9667c256c256dafcc62d21328ce7a40067da857969b68ee9af375b0aaf72/websockets-16.1.1-cp314-cp314-win32.whl", hash = "sha256:195c978b065fa40910582464f99d6b15c8b314c68e0546549a55ed83f4735328", size = 179603, upload-time = "2026-07-17T22:50:27.086Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bd/dd/1c099d6c0fc5deb6b46ccdbb6981fdb4b12c917869cb3952408409dc18db/websockets-16.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:4e8d01cc3bcae7bbf8167f944aeafefed590fae5693552bba9794a9df68371cc", size = 179948, upload-time = "2026-07-17T22:50:28.521Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/25/9956b2d5e0529d5d23924f21bba1440d4c5c88a562e4f08550871ffa97a7/websockets-16.1.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0ffd3031ea8bda8d61762e84220186105ba3b748b3c8da2ae4f7816fac03e573", size = 179963, upload-time = "2026-07-17T22:50:29.982Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/17/06/55ffc976c488b6aee9ea05761ff7c4e88e7c1fd82818c8ca7b556ad2f90c/websockets-16.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:84a2cef8deffbd9ab8ee0ea546a2a6a7030c28f44e6cdd4547dbfeb489eb8999", size = 177497, upload-time = "2026-07-17T22:50:31.396Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0c/e8/f7dac2e980bacc92bdc26cebae4ae4d50cae5380732c50980598fc0bbae4/websockets-16.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3df13f73af9b3b38ab1195eb299ecb67a4330c911c97ae04043ff74085728abe", size = 177698, upload-time = "2026-07-17T22:50:32.829Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/39/26762f734113e22da2b942c3aca85798e0c0405d64c256549540ff31e5a1/websockets-16.1.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:23253dd5bcae3f9aaee0a1d30967a8dbd52e5d3cff93a2e5b84df57b77d4750d", size = 187561, upload-time = "2026-07-17T22:50:34.24Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/11/94/c3f330851806b9b02138b774d593478323e73c99238681b4b93efe64e02d/websockets-16.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c1c5705e314449e3308872fe084b8571ce078ee4fc55a98a769bdefe5917392", size = 188732, upload-time = "2026-07-17T22:50:36.088Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/f2/eb2c450f052de334ae33cf200ece6e87b0e14d186807074e4eb1cd2cdea2/websockets-16.1.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:69e52d175a0a7d1e13b4b67ad41c560b7d98e8c6f6126eb0bda496c784faf8c7", size = 190872, upload-time = "2026-07-17T22:50:38.008Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/31/2ac8cecf3a74f7fed9132129fc3d90b3998a1554570c11a69b2a8c20332d/websockets-16.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f79c89b5eb034d1722938a891916582f8f7f503f58ca22518a63c3f2cd18499", size = 189305, upload-time = "2026-07-17T22:50:39.53Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/cf/8ab19650d3c0d4562c92e70ab47c257c4aa5c6a713ed87fe63766b31fefc/websockets-16.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:39f2a024af5c345ffe8fcf1ee18c049c024c94df393bb09b044a6917c77bde43", size = 188033, upload-time = "2026-07-17T22:50:40.912Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/66/d7/a49a38a6127a4acb134fb1912b215d900cc657605cff32445bf519f3acc4/websockets-16.1.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:952303a7318d4cbe1011400839bb2051c9f84fa0a35923267f5daba34b15d458", size = 185748, upload-time = "2026-07-17T22:50:42.559Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/95/3e/ad1fa40388c7f2e0bb2c7930d0090b6c5498594bd1cdaec18864df3d9e97/websockets-16.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:249116b4a76063d930a46391ad56e135c286e4562a18309029fc2c73f4ed4c62", size = 188285, upload-time = "2026-07-17T22:50:43.974Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/35/b8/d5db28ca264b9104f82196f92dc8843e35fd391f763d42e4ad358f5bc97e/websockets-16.1.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:61922544a0587a13fd3f53e4c0e5e606510c7b0d9d22c8444e5fae22a06b38cb", size = 186777, upload-time = "2026-07-17T22:50:45.474Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/42/9c/726cb39d0cc43ae848dce4aa2acb04eecc6738b1264ec6d700bf6bcfb9f8/websockets-16.1.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:46dcaa042cd1de6c59e7d9269fa63ff7572b6df40510600b678f0826b3c7af51", size = 188682, upload-time = "2026-07-17T22:50:46.973Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/c7/1168704de8c2dd483edabe4a22cbe4465dd8be8dd95561d214f9fe092871/websockets-16.1.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:38565aca3e01ea8734e578fb2118dade0ecb0250533f29e22b8d1a7a196cf4d0", size = 186377, upload-time = "2026-07-17T22:50:48.413Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ca/40/f9ff2d630ffce4e7dfea0b2288e1caf9ebbf9ff8a9ec9396136ce8b94935/websockets-16.1.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:42f599f4d48c7e1a3338fdaac3acd075be3b3cf02d4b274f3bf2767aedd3d217", size = 187148, upload-time = "2026-07-17T22:50:49.845Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/71/e177c8299f78d7cbe2d14df228643c10c70c0e86e108e092056bbcc16e46/websockets-16.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dcc04fedf83effaeb9cce98abc9469bb1b42ef85f03e01c8c1f4438ef7555737", size = 187578, upload-time = "2026-07-17T22:50:51.619Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/b2/b6987faf330f5af5c787a2610124c2e8403d51724f9001ec4fff6311fe7a/websockets-16.1.1-cp314-cp314t-win32.whl", hash = "sha256:8483c2096363120eea8b07c06ae7304d520f686665fffd4811fad423930a65d7", size = 179729, upload-time = "2026-07-17T22:50:53.269Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a2/6e/fbac6ed878dd362fbad7d415fa4f84d38e3e33fed8cde45c64e783acf826/websockets-16.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bcce07e23e5769375158f5efdcdafa8d5cd014b93c6683865b840ed65b96f231", size = 180072, upload-time = "2026-07-17T22:50:54.969Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e1/ed/71fea6e141590cafc40b14dc5943b0845606bee87bdb52a21b6a73eb4311/websockets-16.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:820fb8450edddae3812fd58cbc08e2bf22812cb248ecb5f06dbb82119a56e869", size = 177185, upload-time = "2026-07-17T22:50:56.665Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/01/ec/00e7eeca200facf9266a83e4cbbf1bed0e67fba1d4d45031d3e5b3d81b5c/websockets-16.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:125f22dbefaf1554fea66fc83851490edb284ce4f501d37ffed2752f418332d9", size = 177459, upload-time = "2026-07-17T22:50:58.197Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/75/fd/5774c4b33f7c0d8f0c51809c8b3a93456c48e3543579262cfa64eb5f522e/websockets-16.1.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:30bbe120437b5648a77d3519b7024ea09530e0b5b18d3698c5a0ae536fe0cc2e", size = 178294, upload-time = "2026-07-17T22:50:59.641Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/37/c3/48e2c03d2bd79bb45948841c592d24156312dd5f58cdf8f549febe652fb6/websockets-16.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6b9dadbef0cccd9f4c4ee96b08898afa73e26803bbe0f6aeb5bb12b0074206d", size = 179190, upload-time = "2026-07-17T22:51:01.129Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/3f/73e511ecf2496ceac57dd4ed8388efe2bcf0769338a2dbf242c8366ae87e/websockets-16.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56cd5fc4f10a9ea8aa0804bddb7b42506cf9e136046f3b4c27de8fec9e2ecba5", size = 180330, upload-time = "2026-07-17T22:51:02.603Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/4d/2d0d67834092e354d2b0498f014a41249a89556bc406cf86f3e1557bb463/websockets-16.1.1-py3-none-any.whl", hash = "sha256:6abbd3e82c731c8e531714466acd5d87b5e88ac3243465337ba71d68e23ae7e3", size = 173814, upload-time = "2026-07-17T22:51:04.184Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xxhash"
|
name = "xxhash"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
|
|||||||
Generated
+30
-19
@@ -3,7 +3,8 @@ revision = 3
|
|||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
resolution-markers = [
|
resolution-markers = [
|
||||||
"python_full_version >= '3.15'",
|
"python_full_version >= '3.15'",
|
||||||
"python_full_version >= '3.11' and python_full_version < '3.15'",
|
"python_full_version >= '3.12' and python_full_version < '3.15' and sys_platform == 'emscripten'",
|
||||||
|
"(python_full_version >= '3.11' and python_full_version < '3.15' and sys_platform != 'emscripten') or (python_full_version == '3.11.*' and sys_platform == 'emscripten')",
|
||||||
"python_full_version < '3.11'",
|
"python_full_version < '3.11'",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -18,16 +19,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.13.0"
|
version = "4.14.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -736,15 +737,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpcore2"
|
name = "httpcore2"
|
||||||
version = "2.5.0"
|
version = "2.12.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "h11" },
|
{ name = "h11" },
|
||||||
{ name = "truststore" },
|
{ name = "truststore" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/47/06/5c12df521b5322fb1114a83d46911b2fbcb8855ddb3a635f11c01a214af5/httpcore2-2.5.0.tar.gz", hash = "sha256:88aa170137c17328d5ac44234f9fd10706466d5fb347f3edac4d39b91137b09d", size = 64808, upload-time = "2026-06-25T14:16:56.472Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/be/ad/f4f0e57345f1870f3e8cb624e058d7eca6e5a27d33bcc3311d9b618734cd/httpcore2-2.12.0.tar.gz", hash = "sha256:9293522bba0aa7c4c8e9e3f040c16575bd8868e155a77fa30c7a9085a5eae648", size = 67548, upload-time = "2026-08-18T13:22:08.211Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c9/a1/7564199d1a8728fe737b0a72e5b3f8d92dfe085a74ddf7cdd83bce5f206d/httpcore2-2.5.0-py3-none-any.whl", hash = "sha256:5ce35188de461d31e8d000bfb8ef8bf22c6c16587a211e5571deaa5e9bdf842a", size = 80330, upload-time = "2026-06-25T14:16:53.634Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/74/d370e55600d9bcfa0d9794b0166126d49291a3d2b20c268fc98c453a4948/httpcore2-2.12.0-py3-none-any.whl", hash = "sha256:7e04258ce01013d7d615e5b910a3b27fac937d7a95038227e79652b4ba3b4ceb", size = 83074, upload-time = "2026-08-18T13:22:05.854Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -802,18 +803,28 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httpx2"
|
name = "httpx2"
|
||||||
version = "2.5.0"
|
version = "2.12.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio", marker = "sys_platform != 'emscripten'" },
|
||||||
{ name = "httpcore2" },
|
{ name = "httpcore2", marker = "sys_platform != 'emscripten'" },
|
||||||
|
{ name = "httpx2-jsfetch", marker = "python_full_version >= '3.12' and sys_platform == 'emscripten'" },
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "truststore" },
|
{ name = "truststore", marker = "sys_platform != 'emscripten'" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/e2/b5dedc0cf35aa65de5f541ccd30d2bc1fd7f1d43c9ab09f8ed9a7342317b/httpx2-2.5.0.tar.gz", hash = "sha256:e2df9cb4611021527ff8a675b1c320b610a2ec397acc8d6fe6e91df2d9b33c29", size = 83121, upload-time = "2026-06-25T14:16:57.491Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/7f/f8/579a8b51e42e38ee32647df9f08aa25643ae788e275cc625b199829c4671/httpx2-2.12.0.tar.gz", hash = "sha256:7631fe9887a8a2275f4a2540e053aa670fcc50742864a9ae7c66e609fdcf12cf", size = 100040, upload-time = "2026-08-18T13:22:09.086Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/22/859d8252dad9bc9adee34b52e62cde621ece07b042ccb2ab4da1be46695f/httpx2-2.5.0-py3-none-any.whl", hash = "sha256:3d2d4d9cf4b61f1a1f46a95947cfdb47e80cb56a2f91c6256ac8f58e4891df41", size = 76652, upload-time = "2026-06-25T14:16:55.23Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/95/411ba65569158e862368917aaf56597f3e5fa3b91b0502919638465a08f3/httpx2-2.12.0-py3-none-any.whl", hash = "sha256:cc8b6eecb8661c146b8f89a60e97456ee086e91a784ed31ac450c3a9e613dd36", size = 95427, upload-time = "2026-08-18T13:22:06.834Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx2-jsfetch"
|
||||||
|
version = "1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/c4/0e5636363151a2a1795e0a77617168b9ca438e1748ec05fc9b5687f93d64/httpx2_jsfetch-1.0.tar.gz", hash = "sha256:70a0e3eabfef7cce5ad9c629f7d01ca05e418f586646f4ddf14782e4c1454c60", size = 6872, upload-time = "2026-08-07T00:13:07.492Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9b/43/832f631d32e4f1211caa2ba368317739fe71f0b8530e4c9d15dc454bac2a/httpx2_jsfetch-1.0-py3-none-any.whl", hash = "sha256:cb916b707601e69a07721aabc8f3f6659be3a6893bc1ff5c6f9e02241df2da32", size = 6382, upload-time = "2026-08-07T00:13:06.567Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1766,11 +1777,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyjwt"
|
name = "pyjwt"
|
||||||
version = "2.13.0"
|
version = "2.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/02/a5/5197bfd06417837ac079921c66fa6393f1dea3557272a263cebfef69e432/pyjwt-2.15.0.tar.gz", hash = "sha256:b11c5f9791d7bf51c2b39a81ed669f6b2dbbd669df2942f6c60167e9e3d1abe4", size = 120513, upload-time = "2026-09-23T16:56:00.689Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/55/40e45bf052ee8ee12a4dfd785519660f8effa7b065442b91646ec6828619/pyjwt-2.15.0-py3-none-any.whl", hash = "sha256:7a3742debf6b879e912dbb9819ceec1594be812452b78c5f2e2dfc56564954f8", size = 33680, upload-time = "2026-09-23T16:55:59.241Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2232,11 +2243,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -114,6 +114,26 @@ def create_metadata_for_update_state_api(
|
|||||||
return new_counters
|
return new_counters
|
||||||
|
|
||||||
|
|
||||||
|
def advance_delta_counters(
|
||||||
|
channels: Mapping[str, BaseChannel],
|
||||||
|
updated_channels: set[str],
|
||||||
|
*,
|
||||||
|
prev_metadata: Mapping[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""The `counters_since_delta_snapshot` entry for an update_state
|
||||||
|
checkpoint saved one superstep after `prev_metadata`'s, for the paths
|
||||||
|
that skip `create_checkpoint_plan_for_update_state_api`.
|
||||||
|
|
||||||
|
Without it, the next checkpoint restarts every delta channel's snapshot
|
||||||
|
cadence from zero.
|
||||||
|
"""
|
||||||
|
counters = create_metadata_for_update_state_api(
|
||||||
|
channels, updated_channels, prev_metadata=prev_metadata
|
||||||
|
)
|
||||||
|
non_zero = {k: v for k, v in counters.items() if v != (0, 0)}
|
||||||
|
return {"counters_since_delta_snapshot": non_zero} if non_zero else {}
|
||||||
|
|
||||||
|
|
||||||
def create_checkpoint_plan_for_update_state_api(
|
def create_checkpoint_plan_for_update_state_api(
|
||||||
channels: Mapping[str, BaseChannel],
|
channels: Mapping[str, BaseChannel],
|
||||||
updated_channels: set[str],
|
updated_channels: set[str],
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ from langgraph.pregel._algo import (
|
|||||||
from langgraph.pregel._call import identifier
|
from langgraph.pregel._call import identifier
|
||||||
from langgraph.pregel._checkpoint import (
|
from langgraph.pregel._checkpoint import (
|
||||||
achannels_from_checkpoint,
|
achannels_from_checkpoint,
|
||||||
|
advance_delta_counters,
|
||||||
channels_from_checkpoint,
|
channels_from_checkpoint,
|
||||||
copy_checkpoint,
|
copy_checkpoint,
|
||||||
create_checkpoint,
|
create_checkpoint,
|
||||||
@@ -1681,6 +1682,7 @@ class Pregel(
|
|||||||
"Cannot apply multiple updates when clearing state"
|
"Cannot apply multiple updates when clearing state"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
updated_channels: set[str] = set()
|
||||||
if saved is not None:
|
if saved is not None:
|
||||||
# tasks for this checkpoint
|
# tasks for this checkpoint
|
||||||
next_tasks = prepare_next_tasks(
|
next_tasks = prepare_next_tasks(
|
||||||
@@ -1703,7 +1705,7 @@ class Pregel(
|
|||||||
for w in saved.pending_writes or []
|
for w in saved.pending_writes or []
|
||||||
if w[0] == NULL_TASK_ID
|
if w[0] == NULL_TASK_ID
|
||||||
]:
|
]:
|
||||||
apply_writes(
|
updated_channels |= apply_writes(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
channels,
|
channels,
|
||||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||||
@@ -1718,7 +1720,7 @@ class Pregel(
|
|||||||
continue
|
continue
|
||||||
next_tasks[tid].writes.append((k, v))
|
next_tasks[tid].writes.append((k, v))
|
||||||
# clear all current tasks
|
# clear all current tasks
|
||||||
apply_writes(
|
updated_channels |= apply_writes(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
channels,
|
channels,
|
||||||
next_tasks.values(),
|
next_tasks.values(),
|
||||||
@@ -1733,6 +1735,13 @@ class Pregel(
|
|||||||
"source": "update",
|
"source": "update",
|
||||||
"step": step + 1,
|
"step": step + 1,
|
||||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||||
|
**(
|
||||||
|
advance_delta_counters(
|
||||||
|
channels, updated_channels, prev_metadata=saved.metadata
|
||||||
|
)
|
||||||
|
if saved
|
||||||
|
else {}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
get_new_channel_versions(
|
get_new_channel_versions(
|
||||||
checkpoint_previous_versions,
|
checkpoint_previous_versions,
|
||||||
@@ -1751,7 +1760,7 @@ class Pregel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if input_writes := deque(map_input(self.input_channels, values)):
|
if input_writes := deque(map_input(self.input_channels, values)):
|
||||||
apply_writes(
|
updated_channels = apply_writes(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
channels,
|
channels,
|
||||||
[PregelTaskWrites((), INPUT, input_writes, [])],
|
[PregelTaskWrites((), INPUT, input_writes, [])],
|
||||||
@@ -1774,6 +1783,15 @@ class Pregel(
|
|||||||
"parents": saved.metadata.get("parents", {})
|
"parents": saved.metadata.get("parents", {})
|
||||||
if saved
|
if saved
|
||||||
else {},
|
else {},
|
||||||
|
**(
|
||||||
|
advance_delta_counters(
|
||||||
|
channels,
|
||||||
|
updated_channels,
|
||||||
|
prev_metadata=saved.metadata,
|
||||||
|
)
|
||||||
|
if saved
|
||||||
|
else {}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
get_new_channel_versions(
|
get_new_channel_versions(
|
||||||
checkpoint_previous_versions,
|
checkpoint_previous_versions,
|
||||||
@@ -1819,6 +1837,12 @@ class Pregel(
|
|||||||
"source": "fork",
|
"source": "fork",
|
||||||
"step": step + 1,
|
"step": step + 1,
|
||||||
"parents": saved.metadata.get("parents", {}),
|
"parents": saved.metadata.get("parents", {}),
|
||||||
|
# The copy has the same values and the same parent.
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in saved.metadata.items()
|
||||||
|
if k == "counters_since_delta_snapshot"
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
@@ -2145,6 +2169,7 @@ class Pregel(
|
|||||||
raise InvalidUpdateError(
|
raise InvalidUpdateError(
|
||||||
"Cannot apply multiple updates when clearing state"
|
"Cannot apply multiple updates when clearing state"
|
||||||
)
|
)
|
||||||
|
updated_channels: set[str] = set()
|
||||||
if saved is not None:
|
if saved is not None:
|
||||||
# tasks for this checkpoint
|
# tasks for this checkpoint
|
||||||
next_tasks = prepare_next_tasks(
|
next_tasks = prepare_next_tasks(
|
||||||
@@ -2167,7 +2192,7 @@ class Pregel(
|
|||||||
for w in saved.pending_writes or []
|
for w in saved.pending_writes or []
|
||||||
if w[0] == NULL_TASK_ID
|
if w[0] == NULL_TASK_ID
|
||||||
]:
|
]:
|
||||||
apply_writes(
|
updated_channels |= apply_writes(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
channels,
|
channels,
|
||||||
[PregelTaskWrites((), INPUT, null_writes, [])],
|
[PregelTaskWrites((), INPUT, null_writes, [])],
|
||||||
@@ -2182,7 +2207,7 @@ class Pregel(
|
|||||||
continue
|
continue
|
||||||
next_tasks[tid].writes.append((k, v))
|
next_tasks[tid].writes.append((k, v))
|
||||||
# clear all current tasks
|
# clear all current tasks
|
||||||
apply_writes(
|
updated_channels |= apply_writes(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
channels,
|
channels,
|
||||||
next_tasks.values(),
|
next_tasks.values(),
|
||||||
@@ -2197,6 +2222,13 @@ class Pregel(
|
|||||||
"source": "update",
|
"source": "update",
|
||||||
"step": step + 1,
|
"step": step + 1,
|
||||||
"parents": saved.metadata.get("parents", {}) if saved else {},
|
"parents": saved.metadata.get("parents", {}) if saved else {},
|
||||||
|
**(
|
||||||
|
advance_delta_counters(
|
||||||
|
channels, updated_channels, prev_metadata=saved.metadata
|
||||||
|
)
|
||||||
|
if saved
|
||||||
|
else {}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
get_new_channel_versions(
|
get_new_channel_versions(
|
||||||
checkpoint_previous_versions, checkpoint["channel_versions"]
|
checkpoint_previous_versions, checkpoint["channel_versions"]
|
||||||
@@ -2214,7 +2246,7 @@ class Pregel(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if input_writes := deque(map_input(self.input_channels, values)):
|
if input_writes := deque(map_input(self.input_channels, values)):
|
||||||
apply_writes(
|
updated_channels = apply_writes(
|
||||||
checkpoint,
|
checkpoint,
|
||||||
channels,
|
channels,
|
||||||
[PregelTaskWrites((), INPUT, input_writes, [])],
|
[PregelTaskWrites((), INPUT, input_writes, [])],
|
||||||
@@ -2237,6 +2269,15 @@ class Pregel(
|
|||||||
"parents": saved.metadata.get("parents", {})
|
"parents": saved.metadata.get("parents", {})
|
||||||
if saved
|
if saved
|
||||||
else {},
|
else {},
|
||||||
|
**(
|
||||||
|
advance_delta_counters(
|
||||||
|
channels,
|
||||||
|
updated_channels,
|
||||||
|
prev_metadata=saved.metadata,
|
||||||
|
)
|
||||||
|
if saved
|
||||||
|
else {}
|
||||||
|
),
|
||||||
},
|
},
|
||||||
get_new_channel_versions(
|
get_new_channel_versions(
|
||||||
checkpoint_previous_versions,
|
checkpoint_previous_versions,
|
||||||
@@ -2282,6 +2323,12 @@ class Pregel(
|
|||||||
"source": "fork",
|
"source": "fork",
|
||||||
"step": step + 1,
|
"step": step + 1,
|
||||||
"parents": saved.metadata.get("parents", {}),
|
"parents": saved.metadata.get("parents", {}),
|
||||||
|
# The copy has the same values and the same parent.
|
||||||
|
**{
|
||||||
|
k: v
|
||||||
|
for k, v in saved.metadata.items()
|
||||||
|
if k == "counters_since_delta_snapshot"
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{},
|
{},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -12,15 +12,23 @@ from typing import (
|
|||||||
Generic,
|
Generic,
|
||||||
Literal,
|
Literal,
|
||||||
NamedTuple,
|
NamedTuple,
|
||||||
TypeVar,
|
|
||||||
final,
|
final,
|
||||||
|
overload,
|
||||||
)
|
)
|
||||||
from warnings import warn
|
from warnings import warn
|
||||||
|
|
||||||
from langchain_core.messages import AnyMessage
|
from langchain_core.messages import AnyMessage
|
||||||
from langchain_core.runnables import Runnable, RunnableConfig
|
from langchain_core.runnables import Runnable, RunnableConfig
|
||||||
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
|
from langgraph.checkpoint.base import BaseCheckpointSaver, CheckpointMetadata
|
||||||
from typing_extensions import NotRequired, TypeAliasType, TypedDict, Unpack, deprecated
|
from pydantic import TypeAdapter
|
||||||
|
from typing_extensions import (
|
||||||
|
NotRequired,
|
||||||
|
TypeAliasType,
|
||||||
|
TypedDict,
|
||||||
|
TypeVar,
|
||||||
|
Unpack,
|
||||||
|
deprecated,
|
||||||
|
)
|
||||||
from xxhash import xxh3_128_hexdigest
|
from xxhash import xxh3_128_hexdigest
|
||||||
|
|
||||||
from langgraph._internal._cache import default_cache_key
|
from langgraph._internal._cache import default_cache_key
|
||||||
@@ -36,6 +44,7 @@ from langgraph.warnings import LangGraphDeprecatedSinceV10, LangGraphDeprecatedS
|
|||||||
# when used in standalone type aliases.
|
# when used in standalone type aliases.
|
||||||
StateT = TypeVar("StateT")
|
StateT = TypeVar("StateT")
|
||||||
OutputT = TypeVar("OutputT")
|
OutputT = TypeVar("OutputT")
|
||||||
|
ResponseT = TypeVar("ResponseT", default=Any)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from langgraph.pregel.protocol import PregelProtocol
|
from langgraph.pregel.protocol import PregelProtocol
|
||||||
@@ -572,7 +581,7 @@ _DEFAULT_INTERRUPT_ID = "placeholder-id"
|
|||||||
|
|
||||||
@final
|
@final
|
||||||
@dataclass(init=False, slots=True)
|
@dataclass(init=False, slots=True)
|
||||||
class Interrupt:
|
class Interrupt(Generic[ResponseT]):
|
||||||
"""Information about an interrupt that occurred in a node.
|
"""Information about an interrupt that occurred in a node.
|
||||||
|
|
||||||
!!! version-added "Added in version 0.2.24"
|
!!! version-added "Added in version 0.2.24"
|
||||||
@@ -596,13 +605,22 @@ class Interrupt:
|
|||||||
id: str
|
id: str
|
||||||
"""The ID of the interrupt. Can be used to resume the interrupt directly."""
|
"""The ID of the interrupt. Can be used to resume the interrupt directly."""
|
||||||
|
|
||||||
|
response_schema: type[ResponseT] | dict[str, Any] | None = None
|
||||||
|
"""Schema for the value expected when resuming this interrupt, if the graph provided one.
|
||||||
|
|
||||||
|
A surfaced interrupt carries JSON Schema (a `dict`); `type[ResponseT]` records the
|
||||||
|
Python type at construction so `Interrupt[Decision]` is meaningful to type checkers."""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
value: Any,
|
value: Any,
|
||||||
id: str = _DEFAULT_INTERRUPT_ID,
|
id: str = _DEFAULT_INTERRUPT_ID,
|
||||||
|
*,
|
||||||
|
response_schema: type[ResponseT] | dict[str, Any] | None = None,
|
||||||
**deprecated_kwargs: Unpack[DeprecatedKwargs],
|
**deprecated_kwargs: Unpack[DeprecatedKwargs],
|
||||||
) -> None:
|
) -> None:
|
||||||
self.value = value
|
self.value = value
|
||||||
|
self.response_schema = response_schema
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(ns := deprecated_kwargs.get("ns", MISSING)) is not MISSING
|
(ns := deprecated_kwargs.get("ns", MISSING)) is not MISSING
|
||||||
@@ -614,8 +632,18 @@ class Interrupt:
|
|||||||
self.id = id
|
self.id = id
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_ns(cls, value: Any, ns: str) -> Interrupt:
|
def from_ns(
|
||||||
return cls(value=value, id=xxh3_128_hexdigest(ns.encode()))
|
cls,
|
||||||
|
value: Any,
|
||||||
|
ns: str,
|
||||||
|
*,
|
||||||
|
response_schema: type[ResponseT] | dict[str, Any] | None = None,
|
||||||
|
) -> Interrupt[ResponseT]:
|
||||||
|
return cls(
|
||||||
|
value=value,
|
||||||
|
id=xxh3_128_hexdigest(ns.encode()),
|
||||||
|
response_schema=response_schema,
|
||||||
|
)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@deprecated("`interrupt_id` is deprecated. Use `id` instead.", category=None)
|
@deprecated("`interrupt_id` is deprecated. Use `id` instead.", category=None)
|
||||||
@@ -848,7 +876,17 @@ class Command(Generic[N], ToolOutputMixin):
|
|||||||
PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
|
PARENT: ClassVar[Literal["__parent__"]] = "__parent__"
|
||||||
|
|
||||||
|
|
||||||
def interrupt(value: Any) -> Any:
|
@overload
|
||||||
|
def interrupt(value: Any, *, response_schema: type[ResponseT]) -> ResponseT: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def interrupt(value: Any, *, response_schema: dict[str, Any] | None = None) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
def interrupt(
|
||||||
|
value: Any, *, response_schema: dict[str, Any] | type | None = None
|
||||||
|
) -> Any:
|
||||||
"""Interrupt the graph with a resumable exception from within a node.
|
"""Interrupt the graph with a resumable exception from within a node.
|
||||||
|
|
||||||
The `interrupt` function enables human-in-the-loop workflows by pausing graph
|
The `interrupt` function enables human-in-the-loop workflows by pausing graph
|
||||||
@@ -918,7 +956,7 @@ def interrupt(value: Any) -> Any:
|
|||||||
for chunk in graph.stream({\"foo\": \"abc\"}, config):
|
for chunk in graph.stream({\"foo\": \"abc\"}, config):
|
||||||
print(chunk)
|
print(chunk)
|
||||||
|
|
||||||
# > {'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06'),)}
|
# > {'__interrupt__': (Interrupt(value='what is your age?', id='45fda8478b2ef754419799e10992af06', response_schema=None),)}
|
||||||
|
|
||||||
command = Command(resume=\"some input from a human!!!\")
|
command = Command(resume=\"some input from a human!!!\")
|
||||||
|
|
||||||
@@ -931,12 +969,20 @@ def interrupt(value: Any) -> Any:
|
|||||||
|
|
||||||
Args:
|
Args:
|
||||||
value: The value to surface to the client when the graph is interrupted.
|
value: The value to surface to the client when the graph is interrupted.
|
||||||
|
response_schema: Optional schema for the value expected on resume, surfaced
|
||||||
|
to clients so they can render a typed input form. Accepts a JSON Schema
|
||||||
|
`dict` (used as-is, resume values are not validated), or a Pydantic model
|
||||||
|
class, `TypedDict`, or dataclass, which are converted to JSON Schema for
|
||||||
|
clients and used to validate the resume value; the validated object is
|
||||||
|
what `interrupt` returns.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Any: On subsequent invocations within the same node (same task to be precise), returns the value provided during the first invocation
|
Any: On subsequent invocations within the same node (same task to be precise), returns the value provided during the first invocation,
|
||||||
|
validated against `response_schema` when one that supports validation was given.
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
GraphInterrupt: On the first invocation within the node, halts execution and surfaces the provided value to the client.
|
GraphInterrupt: On the first invocation within the node, halts execution and surfaces the provided value to the client.
|
||||||
|
pydantic.ValidationError: When a resume value does not match a Pydantic model, `TypedDict`, or dataclass `response_schema`.
|
||||||
"""
|
"""
|
||||||
from langgraph._internal._constants import (
|
from langgraph._internal._constants import (
|
||||||
CONFIG_KEY_CHECKPOINT_NS,
|
CONFIG_KEY_CHECKPOINT_NS,
|
||||||
@@ -948,27 +994,36 @@ def interrupt(value: Any) -> Any:
|
|||||||
from langgraph.errors import GraphInterrupt
|
from langgraph.errors import GraphInterrupt
|
||||||
|
|
||||||
conf = get_config()["configurable"]
|
conf = get_config()["configurable"]
|
||||||
|
adapter = (
|
||||||
|
None
|
||||||
|
if response_schema is None or isinstance(response_schema, dict)
|
||||||
|
else TypeAdapter(response_schema)
|
||||||
|
)
|
||||||
# track interrupt index
|
# track interrupt index
|
||||||
scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
|
scratchpad = conf[CONFIG_KEY_SCRATCHPAD]
|
||||||
idx = scratchpad.interrupt_counter()
|
idx = scratchpad.interrupt_counter()
|
||||||
# find previous resume values
|
# find previous resume values
|
||||||
if scratchpad.resume:
|
if scratchpad.resume:
|
||||||
if idx < len(scratchpad.resume):
|
if idx < len(scratchpad.resume):
|
||||||
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
|
v = scratchpad.resume[idx]
|
||||||
return scratchpad.resume[idx]
|
validated = adapter.validate_python(v) if adapter else v
|
||||||
|
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume[: idx + 1])])
|
||||||
|
return validated
|
||||||
# find current resume value
|
# find current resume value
|
||||||
v = scratchpad.get_null_resume(True)
|
v = scratchpad.get_null_resume(True)
|
||||||
if v is not None:
|
if v is not None:
|
||||||
assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
|
assert len(scratchpad.resume) == idx, (scratchpad.resume, idx)
|
||||||
|
validated = adapter.validate_python(v) if adapter else v
|
||||||
scratchpad.resume.append(v)
|
scratchpad.resume.append(v)
|
||||||
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
|
conf[CONFIG_KEY_SEND]([(RESUME, scratchpad.resume)])
|
||||||
return v
|
return validated
|
||||||
# no resume value found
|
# no resume value found
|
||||||
raise GraphInterrupt(
|
raise GraphInterrupt(
|
||||||
(
|
(
|
||||||
Interrupt.from_ns(
|
Interrupt.from_ns(
|
||||||
value=value,
|
value=value,
|
||||||
ns=conf[CONFIG_KEY_CHECKPOINT_NS],
|
ns=conf[CONFIG_KEY_CHECKPOINT_NS],
|
||||||
|
response_schema=adapter.json_schema() if adapter else response_schema,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "langgraph"
|
name = "langgraph"
|
||||||
version = "1.2.11"
|
version = "1.2.12"
|
||||||
description = "Building stateful, multi-actor applications with LLMs"
|
description = "Building stateful, multi-actor applications with LLMs"
|
||||||
authors = []
|
authors = []
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@@ -147,6 +147,60 @@ async def test_predicate_fires_on_supersteps_overflow() -> None:
|
|||||||
assert "x" not in result2
|
assert "x" not in result2
|
||||||
|
|
||||||
|
|
||||||
|
def _delta_counters(saver: InMemorySaver, config: Any) -> dict[str, list[int]]:
|
||||||
|
tup = saver.get_tuple(config)
|
||||||
|
assert tup is not None
|
||||||
|
counters = tup.metadata.get("counters_since_delta_snapshot") or {}
|
||||||
|
return {ch: list(c) for ch, c in counters.items()}
|
||||||
|
|
||||||
|
|
||||||
|
_UPDATE_PATHS_WITHOUT_THE_SNAPSHOT_PLAN = pytest.mark.parametrize(
|
||||||
|
("values", "as_node", "supersteps"),
|
||||||
|
[
|
||||||
|
(None, END, 1),
|
||||||
|
(None, "__copy__", 0),
|
||||||
|
({"a": []}, "__input__", 1),
|
||||||
|
],
|
||||||
|
ids=["clear as END", "copy", "update as input"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@_UPDATE_PATHS_WITHOUT_THE_SNAPSHOT_PLAN
|
||||||
|
def test_update_state_path_keeps_delta_counters(
|
||||||
|
values: Any, as_node: str, supersteps: int
|
||||||
|
) -> None:
|
||||||
|
saver = InMemorySaver()
|
||||||
|
graph = _build_two_channel_graph(saver)
|
||||||
|
config = {"configurable": {"thread_id": "counters"}}
|
||||||
|
graph.invoke({"a": ["seed-a"], "b": ["seed-b"]}, config)
|
||||||
|
before = _delta_counters(saver, config)
|
||||||
|
assert set(before) == {"a", "b"}, f"both channels need live counters: {before}"
|
||||||
|
|
||||||
|
updated = graph.update_state(config, values, as_node=as_node)
|
||||||
|
|
||||||
|
assert _delta_counters(saver, updated) == {
|
||||||
|
ch: [u, s + supersteps] for ch, (u, s) in before.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@_UPDATE_PATHS_WITHOUT_THE_SNAPSHOT_PLAN
|
||||||
|
async def test_aupdate_state_path_keeps_delta_counters(
|
||||||
|
values: Any, as_node: str, supersteps: int
|
||||||
|
) -> None:
|
||||||
|
saver = InMemorySaver()
|
||||||
|
graph = _build_two_channel_graph(saver)
|
||||||
|
config = {"configurable": {"thread_id": "counters"}}
|
||||||
|
await graph.ainvoke({"a": ["seed-a"], "b": ["seed-b"]}, config)
|
||||||
|
before = _delta_counters(saver, config)
|
||||||
|
assert set(before) == {"a", "b"}, f"both channels need live counters: {before}"
|
||||||
|
|
||||||
|
updated = await graph.aupdate_state(config, values, as_node=as_node)
|
||||||
|
|
||||||
|
assert _delta_counters(saver, updated) == {
|
||||||
|
ch: [u, s + supersteps] for ch, (u, s) in before.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_counter_reset_after_supersteps_snapshot() -> None:
|
async def test_counter_reset_after_supersteps_snapshot() -> None:
|
||||||
"""After the supersteps bound triggers a snapshot, the counters for
|
"""After the supersteps bound triggers a snapshot, the counters for
|
||||||
that channel reset. Verify by using a bound higher than one run's
|
that channel reset. Verify by using a bound higher than one run's
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from langgraph.checkpoint.base import BaseCheckpointSaver
|
from langgraph.checkpoint.base import BaseCheckpointSaver
|
||||||
|
from pydantic import BaseModel, ValidationError
|
||||||
from typing_extensions import TypedDict
|
from typing_extensions import TypedDict
|
||||||
|
|
||||||
from langgraph.graph import END, START, StateGraph
|
from langgraph.graph import END, START, StateGraph
|
||||||
from langgraph.types import Durability
|
from langgraph.types import Command, Durability, Interrupt, interrupt
|
||||||
|
from tests.any_str import AnyStr
|
||||||
|
|
||||||
pytestmark = pytest.mark.anyio
|
pytestmark = pytest.mark.anyio
|
||||||
|
|
||||||
@@ -90,3 +95,150 @@ async def test_interruption_without_state_updates_async(
|
|||||||
assert (await graph.aget_state(thread)).next == ()
|
assert (await graph.aget_state(thread)).next == ()
|
||||||
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
|
n_checkpoints = len([c async for c in graph.aget_state_history(thread)])
|
||||||
assert n_checkpoints == (5 if durability != "exit" else 3)
|
assert n_checkpoints == (5 if durability != "exit" else 3)
|
||||||
|
|
||||||
|
|
||||||
|
class Decision(BaseModel):
|
||||||
|
approved: bool
|
||||||
|
note: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DecisionDict(TypedDict):
|
||||||
|
approved: bool
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DecisionData:
|
||||||
|
approved: bool
|
||||||
|
|
||||||
|
|
||||||
|
RAW_SCHEMA = {"type": "object", "properties": {"approved": {"type": "boolean"}}}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("response_schema", "expected_schema", "expected_answer"),
|
||||||
|
[
|
||||||
|
(None, None, {"approved": True, "extra": 1}),
|
||||||
|
(RAW_SCHEMA, RAW_SCHEMA, {"approved": True, "extra": 1}),
|
||||||
|
(Decision, Decision.model_json_schema(), Decision(approved=True)),
|
||||||
|
(
|
||||||
|
DecisionDict,
|
||||||
|
{
|
||||||
|
"properties": {"approved": {"title": "Approved", "type": "boolean"}},
|
||||||
|
"required": ["approved"],
|
||||||
|
"title": "DecisionDict",
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
{"approved": True},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
DecisionData,
|
||||||
|
{
|
||||||
|
"properties": {"approved": {"title": "Approved", "type": "boolean"}},
|
||||||
|
"required": ["approved"],
|
||||||
|
"title": "DecisionData",
|
||||||
|
"type": "object",
|
||||||
|
},
|
||||||
|
DecisionData(approved=True),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
ids=["none", "raw_dict", "pydantic", "typeddict", "dataclass"],
|
||||||
|
)
|
||||||
|
def test_interrupt_response_schema(
|
||||||
|
sync_checkpointer: BaseCheckpointSaver,
|
||||||
|
response_schema: Any,
|
||||||
|
expected_schema: dict[str, Any] | None,
|
||||||
|
expected_answer: Any,
|
||||||
|
) -> None:
|
||||||
|
class State(TypedDict):
|
||||||
|
answer: Any
|
||||||
|
|
||||||
|
def node(state: State) -> State:
|
||||||
|
return {
|
||||||
|
"answer": interrupt(
|
||||||
|
{"question": "approve?"}, response_schema=response_schema
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
graph = (
|
||||||
|
StateGraph(State)
|
||||||
|
.add_node("node", node)
|
||||||
|
.add_edge(START, "node")
|
||||||
|
.compile(checkpointer=sync_checkpointer)
|
||||||
|
)
|
||||||
|
config = {"configurable": {"thread_id": "1"}}
|
||||||
|
expected = Interrupt(
|
||||||
|
value={"question": "approve?"}, id=AnyStr(), response_schema=expected_schema
|
||||||
|
)
|
||||||
|
|
||||||
|
assert list(graph.stream({"answer": None}, config)) == [
|
||||||
|
{"__interrupt__": (expected,)}
|
||||||
|
]
|
||||||
|
assert graph.get_state(config).tasks[0].interrupts == (expected,)
|
||||||
|
assert graph.invoke(Command(resume={"approved": True, "extra": 1}), config) == {
|
||||||
|
"answer": expected_answer
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("resume_style", ["null", "map"])
|
||||||
|
def test_interrupt_response_schema_rejects_invalid_resume(
|
||||||
|
sync_checkpointer: BaseCheckpointSaver, resume_style: str
|
||||||
|
) -> None:
|
||||||
|
class State(TypedDict):
|
||||||
|
answer: Any
|
||||||
|
|
||||||
|
def node(state: State) -> State:
|
||||||
|
return {"answer": interrupt("approve?", response_schema=Decision)}
|
||||||
|
|
||||||
|
graph = (
|
||||||
|
StateGraph(State)
|
||||||
|
.add_node("node", node)
|
||||||
|
.add_edge(START, "node")
|
||||||
|
.compile(checkpointer=sync_checkpointer)
|
||||||
|
)
|
||||||
|
config = {"configurable": {"thread_id": "1"}}
|
||||||
|
graph.invoke({"answer": None}, config)
|
||||||
|
[pending] = graph.get_state(config).tasks[0].interrupts
|
||||||
|
|
||||||
|
def resume(value: dict[str, Any]) -> Command:
|
||||||
|
return Command(resume=value if resume_style == "null" else {pending.id: value})
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="approved"):
|
||||||
|
graph.invoke(resume({"approved": "nope"}), config)
|
||||||
|
|
||||||
|
assert graph.invoke(resume({"approved": False}), config) == {
|
||||||
|
"answer": Decision(approved=False)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("resume_style", ["null", "id_map"])
|
||||||
|
def test_interrupt_response_schema_invalid_resume_after_earlier_interrupt(
|
||||||
|
sync_checkpointer: BaseCheckpointSaver, resume_style: str
|
||||||
|
) -> None:
|
||||||
|
class State(TypedDict):
|
||||||
|
answer: Any
|
||||||
|
|
||||||
|
def node(state: State) -> State:
|
||||||
|
first = interrupt("first")
|
||||||
|
second = interrupt("approve?", response_schema=Decision)
|
||||||
|
return {"answer": [first, second]}
|
||||||
|
|
||||||
|
graph = (
|
||||||
|
StateGraph(State)
|
||||||
|
.add_node("node", node)
|
||||||
|
.add_edge(START, "node")
|
||||||
|
.compile(checkpointer=sync_checkpointer)
|
||||||
|
)
|
||||||
|
config = {"configurable": {"thread_id": "1"}}
|
||||||
|
graph.invoke({"answer": None}, config)
|
||||||
|
graph.invoke(Command(resume="ok"), config)
|
||||||
|
[pending] = graph.get_state(config).tasks[0].interrupts
|
||||||
|
|
||||||
|
def resume(value: dict[str, Any]) -> Command:
|
||||||
|
return Command(resume=value if resume_style == "null" else {pending.id: value})
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError, match="approved"):
|
||||||
|
graph.invoke(resume({"approved": "nope"}), config)
|
||||||
|
|
||||||
|
assert graph.invoke(resume({"approved": True}), config) == {
|
||||||
|
"answer": ["ok", Decision(approved=True)]
|
||||||
|
}
|
||||||
|
|||||||
@@ -5583,6 +5583,7 @@ def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver):
|
|||||||
"interrupts": [
|
"interrupts": [
|
||||||
{
|
{
|
||||||
"id": AnyStr(),
|
"id": AnyStr(),
|
||||||
|
"response_schema": None,
|
||||||
"value": "test",
|
"value": "test",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -5627,6 +5628,7 @@ def test_falsy_return_from_task(sync_checkpointer: BaseCheckpointSaver):
|
|||||||
"interrupts": (
|
"interrupts": (
|
||||||
{
|
{
|
||||||
"id": AnyStr(),
|
"id": AnyStr(),
|
||||||
|
"response_schema": None,
|
||||||
"value": "test",
|
"value": "test",
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Generated
+10
-10
@@ -1437,7 +1437,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph"
|
name = "langgraph"
|
||||||
version = "1.2.11"
|
version = "1.2.12"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -2841,11 +2841,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pyjwt"
|
name = "pyjwt"
|
||||||
version = "2.13.0"
|
version = "2.15.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/43/ea/5194e52748b0da83d71e082d75496eaec6e58f419f5e184786ded517e6a9/pyjwt-2.15.1.tar.gz", hash = "sha256:4f259e80cdfb6b3fc18a7de51fd1ef9ec79652f25019bae68975ca2468a34df8", size = 121252, upload-time = "2026-09-28T18:40:42.598Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" },
|
{ url = "https://files.pythonhosted.org/packages/50/ca/44de4e75f8aadc457f0634be3b542815078ded46dca30efb960edeecad6e/pyjwt-2.15.1-py3-none-any.whl", hash = "sha256:42d59d631f7768a1028a64c7ff581a9bf7519804daf91fc5b6c56e30eec5e193", size = 33860, upload-time = "2026-09-28T18:40:41.429Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3404,11 +3404,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "soupsieve"
|
name = "soupsieve"
|
||||||
version = "2.8.4"
|
version = "2.9"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/80/f1/93422647dd7e461f23d254e6b2bfa687a85b53aeb4903fcdbb74474d4584/soupsieve-2.9.tar.gz", hash = "sha256:acee8417325c5653e1377dc31eccad59eb82cbc65942afe6174c53b3aaad63fc", size = 122122, upload-time = "2026-07-19T01:35:18.425Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/d6/3185ab5ad1280319b31986898f3206dd7227cd75e293d4dba2a5e6bf27a0/soupsieve-2.9-py3-none-any.whl", hash = "sha256:a2b2c76d67df2382d245409fd71e321a571717e58463efa32ace87dcadac2c12", size = 37387, upload-time = "2026-07-19T01:35:17.106Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3687,11 +3687,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Generated
+4
-4
@@ -286,7 +286,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph"
|
name = "langgraph"
|
||||||
version = "1.2.11"
|
version = "1.2.12"
|
||||||
source = { editable = "../langgraph" }
|
source = { editable = "../langgraph" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -1363,11 +1363,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from langgraph_sdk.client import get_client, get_sync_client
|
|||||||
from langgraph_sdk.encryption import Encryption
|
from langgraph_sdk.encryption import Encryption
|
||||||
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
|
from langgraph_sdk.encryption.types import DecryptResult, EncryptionContext
|
||||||
|
|
||||||
__version__ = "0.4.4"
|
__version__ = "0.4.5"
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Auth",
|
"Auth",
|
||||||
|
|||||||
@@ -218,8 +218,6 @@ class BaseUser(typing.Protocol):
|
|||||||
class StudioUser:
|
class StudioUser:
|
||||||
"""A user object that's populated from authenticated requests from the LangGraph studio.
|
"""A user object that's populated from authenticated requests from the LangGraph studio.
|
||||||
|
|
||||||
`ls_user_id` is the canonical LangSmith user ID when supplied by Agent Server.
|
|
||||||
|
|
||||||
Note: Studio auth can be disabled in your `langgraph.json` config.
|
Note: Studio auth can be disabled in your `langgraph.json` config.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -252,11 +250,10 @@ class StudioUser:
|
|||||||
```
|
```
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__slots__ = ("_is_authenticated", "_permissions", "ls_user_id", "username")
|
__slots__ = ("_is_authenticated", "_permissions", "username")
|
||||||
|
|
||||||
def __init__(self, username: str, is_authenticated: bool = False) -> None:
|
def __init__(self, username: str, is_authenticated: bool = False) -> None:
|
||||||
self.username = username
|
self.username = username
|
||||||
self.ls_user_id: str | None = None
|
|
||||||
self._is_authenticated = is_authenticated
|
self._is_authenticated = is_authenticated
|
||||||
self._permissions = ["authenticated"] if is_authenticated else []
|
self._permissions = ["authenticated"] if is_authenticated else []
|
||||||
|
|
||||||
|
|||||||
@@ -295,6 +295,8 @@ class Interrupt(TypedDict):
|
|||||||
"""The value associated with the interrupt."""
|
"""The value associated with the interrupt."""
|
||||||
id: str
|
id: str
|
||||||
"""The ID of the interrupt. Can be used to resume the interrupt."""
|
"""The ID of the interrupt. Can be used to resume the interrupt."""
|
||||||
|
response_schema: NotRequired[dict[str, Any]]
|
||||||
|
"""JSON Schema for the value expected when resuming this interrupt, if the graph provided one."""
|
||||||
|
|
||||||
|
|
||||||
class Thread(TypedDict):
|
class Thread(TypedDict):
|
||||||
|
|||||||
@@ -29,7 +29,10 @@ def test_sync_extension_projection_yields_matching_custom_payloads():
|
|||||||
{"name": "progress", "step": 1},
|
{"name": "progress", "step": 1},
|
||||||
{"name": "progress", "step": 2},
|
{"name": "progress", "step": 2},
|
||||||
]
|
]
|
||||||
assert "custom:progress" in fake.stream_request_bodies[-1]["channels"]
|
assert any(
|
||||||
|
"custom:progress" in body.get("channels", [])
|
||||||
|
for body in fake.stream_request_bodies
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_sync_extension_projection_supports_namespace_scope_on_subgraph_handle():
|
def test_sync_extension_projection_supports_namespace_scope_on_subgraph_handle():
|
||||||
|
|||||||
@@ -1,22 +1,8 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from typing_extensions import assert_type
|
|
||||||
|
|
||||||
from langgraph_sdk import Auth
|
from langgraph_sdk import Auth
|
||||||
|
|
||||||
|
|
||||||
def test_studio_user_langsmith_id() -> None:
|
|
||||||
def langsmith_id(user: object) -> str | None:
|
|
||||||
if isinstance(user, Auth.types.StudioUser):
|
|
||||||
return assert_type(user.ls_user_id, str | None)
|
|
||||||
return None
|
|
||||||
|
|
||||||
user = Auth.types.StudioUser("login-user", is_authenticated=True)
|
|
||||||
assert langsmith_id(user) is None
|
|
||||||
user.ls_user_id = "langsmith-user"
|
|
||||||
assert langsmith_id(user) == "langsmith-user"
|
|
||||||
assert user.identity == "login-user"
|
|
||||||
|
|
||||||
|
|
||||||
def test_handler_multiple_resources_and_actions() -> None:
|
def test_handler_multiple_resources_and_actions() -> None:
|
||||||
auth = Auth()
|
auth = Auth()
|
||||||
|
|
||||||
|
|||||||
Generated
+36
-16
@@ -17,16 +17,16 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "anyio"
|
name = "anyio"
|
||||||
version = "4.12.1"
|
version = "4.15.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/96/f0/5eb65b2bb0d09ac6776f2eb54adee6abe8228ea05b20a5ad0e4945de8aac/anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703", size = 228685, upload-time = "2026-01-06T11:45:21.246Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/38/0e/27be9fdef66e72d64c0cdc3cc2823101b80585f8119b5c112c2e8f5f7dab/anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c", size = 113592, upload-time = "2026-01-06T11:45:19.497Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -181,7 +181,7 @@ name = "exceptiongroup"
|
|||||||
version = "1.3.1"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -277,7 +277,8 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "tenacity" },
|
{ name = "tenacity" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||||
|
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||||
{ name = "uuid-utils" },
|
{ name = "uuid-utils" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/90/12/aff76ca89c219ebe6f9dd3c5dbc4e3b1cf5450e9fc7037dccad23d45cd7a/langchain_core-1.6.1.tar.gz", hash = "sha256:1b156cb395aac4f009a8a1b38a574c7d948fe2d5f74c96e0d8a5017b4149e04f", size = 1003359, upload-time = "2026-08-27T19:31:14.956Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/90/12/aff76ca89c219ebe6f9dd3c5dbc4e3b1cf5450e9fc7037dccad23d45cd7a/langchain_core-1.6.1.tar.gz", hash = "sha256:1b156cb395aac4f009a8a1b38a574c7d948fe2d5f74c96e0d8a5017b4149e04f", size = 1003359, upload-time = "2026-08-27T19:31:14.956Z" }
|
||||||
@@ -290,7 +291,8 @@ name = "langchain-protocol"
|
|||||||
version = "0.0.19"
|
version = "0.0.19"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||||
|
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/14/56/913599f2f9cec8524868929f12d72b2ede377a6056ca8a40a32bdadfa535/langchain_protocol-0.0.19.tar.gz", hash = "sha256:79d90a1425122ac87e8052e2ec054fbd09c3edbf341bdfb6397112a495c7bf8c", size = 6265, upload-time = "2026-08-26T21:12:00.703Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/14/56/913599f2f9cec8524868929f12d72b2ede377a6056ca8a40a32bdadfa535/langchain_protocol-0.0.19.tar.gz", hash = "sha256:79d90a1425122ac87e8052e2ec054fbd09c3edbf341bdfb6397112a495c7bf8c", size = 6265, upload-time = "2026-08-26T21:12:00.703Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -299,7 +301,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph"
|
name = "langgraph"
|
||||||
version = "1.2.11"
|
version = "1.2.12"
|
||||||
source = { editable = "../langgraph" }
|
source = { editable = "../langgraph" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -726,7 +728,8 @@ source = { registry = "https://pypi.org/simple" }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "annotated-types" },
|
{ name = "annotated-types" },
|
||||||
{ name = "pydantic-core" },
|
{ name = "pydantic-core" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||||
|
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||||
{ name = "typing-inspection" },
|
{ name = "typing-inspection" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" }
|
||||||
@@ -739,7 +742,8 @@ name = "pydantic-core"
|
|||||||
version = "2.46.5"
|
version = "2.46.5"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||||
|
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -884,7 +888,7 @@ source = { registry = "https://pypi.org/simple" }
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
{ name = "backports-asyncio-runner", marker = "python_full_version < '3.11'" },
|
||||||
{ name = "pytest" },
|
{ name = "pytest" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1037,7 +1041,7 @@ version = "1.6.0"
|
|||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "anyio" },
|
{ name = "anyio" },
|
||||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1136,17 +1140,33 @@ wheels = [
|
|||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version >= '3.15'",
|
||||||
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "typing-extensions"
|
||||||
|
version = "4.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
resolution-markers = [
|
||||||
|
"python_full_version < '3.15'",
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-inspection"
|
name = "typing-inspection"
|
||||||
version = "0.4.2"
|
version = "0.4.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.15'" },
|
||||||
|
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.15'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
@@ -1155,11 +1175,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.7.0"
|
version = "2.8.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972, upload-time = "2026-09-15T19:29:36.253Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
{ url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717, upload-time = "2026-09-15T19:29:34.577Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user