mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-08-18 05:35:43 +02:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
18a2a2a832 | ||
|
|
5ae0aff522 | ||
|
|
b7d068677c | ||
|
|
706f3e981e | ||
|
|
344ab65351 | ||
|
|
94698c8a34 | ||
|
|
7d557cb865 | ||
|
|
f211fadc2b | ||
|
|
024468c5dd | ||
|
|
7f08a6dafd | ||
|
|
76203e2c20 | ||
|
|
b6cc022861 | ||
|
|
02bb7a436a | ||
|
|
43d21e9c7d | ||
|
|
55ed7d4b49 | ||
|
|
2b78bf3bad | ||
|
|
630bd9ab95 | ||
|
|
b945b1f21e | ||
|
|
4cb108ccd9 | ||
|
|
23d78c2817 | ||
|
|
f8c1a323cc | ||
|
|
b7e329cf0a | ||
|
|
3f49249e72 |
@@ -38,7 +38,7 @@ def main():
|
||||
tree = ast.parse(file.read())
|
||||
|
||||
classes = find_classes(tree)
|
||||
|
||||
|
||||
def is_sync(class_spec: Tuple[str, List[str]]) -> bool:
|
||||
return class_spec[0].startswith("Sync")
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
run:
|
||||
working-directory: libs/cli
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: Ana06/get-changed-files@v2.3.0
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
- "3.12"
|
||||
name: "lint #${{ matrix.python-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Get changed files
|
||||
id: changed-files
|
||||
uses: Ana06/get-changed-files@v2.3.0
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
|
||||
name: "test #${{ matrix.python-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
working-directory: libs/langgraph
|
||||
name: "test #${{ matrix.python-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
version: ${{ steps.check-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python $${ env.PYTHON_VERSION }}
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/download-artifact@v6
|
||||
with:
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
run:
|
||||
working-directory: libs/langgraph
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- run: SHA=$(git rev-parse HEAD) && echo "SHA=$SHA" >> $GITHUB_ENV
|
||||
- name: Set up Python 3.11
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
run:
|
||||
working-directory: libs/langgraph
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- id: files
|
||||
name: Get changed files
|
||||
uses: Ana06/get-changed-files@v2.3.0
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
python: ${{ steps.filter.outputs.python }}
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
name: "Check SDK methods matching"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
@@ -118,7 +118,7 @@ jobs:
|
||||
python-version:
|
||||
- "3.11"
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
name: Deploy Docs Redirects
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: "pages"
|
||||
cancel-in-progress: false
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: docs
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10 # Job will be cancelled if it runs for more than 10 minutes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.MKDOCS_GITHUB_TOKEN }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: "3.12"
|
||||
enable-cache: true
|
||||
cache-suffix: "docs"
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
yarn
|
||||
uv sync --all-groups
|
||||
# we run this installation only for internal PRs
|
||||
# as GITHUB_TOKEN is not available for PRs from outside contributors
|
||||
if [ -n "${GITHUB_TOKEN}" ]; then
|
||||
uv run pip install "git+https://${GITHUB_TOKEN}@github.com/langchain-ai/mkdocs-material-insiders.git"
|
||||
fi
|
||||
|
||||
- name: Build llms-text
|
||||
run: make llms-text
|
||||
|
||||
- name: Build site (redirects only)
|
||||
run: make build-docs
|
||||
env:
|
||||
OPENAI_API_KEY: sf-proj-1234567890 # fake placeholder, shouldn't actually be used
|
||||
ANTHROPIC_API_KEY: sk-ant-api03-1234567890 # fake placeholder, shouldn't actually be used
|
||||
|
||||
- name: Configure GitHub Pages
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/configure-pages@v5
|
||||
|
||||
- name: Upload Pages Artifact
|
||||
if: github.ref == 'refs/heads/main'
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
with:
|
||||
path: ./docs/site/
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
if: github.ref == 'refs/heads/main'
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
tag: ${{ steps.check-version.outputs.tag }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -86,7 +86,7 @@ jobs:
|
||||
outputs:
|
||||
release-body: ${{ steps.generate-release-body.outputs.release-body }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: langchain-ai/langgraph
|
||||
path: langgraph
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
- test-pypi-publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# We explicitly *don't* set up caching here. This ensures our tests are
|
||||
# maximally sensitive to catching breakage.
|
||||
@@ -260,7 +260,7 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -301,7 +301,7 @@ jobs:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Python
|
||||
uses: astral-sh/setup-uv@v7
|
||||
@@ -322,5 +322,6 @@ jobs:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
generateReleaseNotes: false
|
||||
tag: ${{needs.build.outputs.tag}}
|
||||
name: ${{ needs.build.outputs.pkg-name }}==${{ needs.build.outputs.version }}
|
||||
body: ${{ needs.release-notes.outputs.release-body }}
|
||||
commit: ${{ github.sha }}
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
- "latest"
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
- name: Set up Python + Poetry
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
|
||||
@@ -67,7 +67,7 @@ While LangGraph can be used standalone, it also integrates seamlessly with any L
|
||||
- [LangChain](https://docs.langchain.com/oss/python/langchain/overview) – Provides integrations and composable components to streamline LLM application development.
|
||||
|
||||
> [!NOTE]
|
||||
> Looking for the JS version of LangGraph? See the [JS repo](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://langchain-ai.github.io/langgraphjs/).
|
||||
> Looking for the JS version of LangGraph? See the [JS repo](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://docs.langchain.com/oss/javascript/langgraph/overview).
|
||||
|
||||
## Additional resources
|
||||
|
||||
|
||||
Binary file not shown.
@@ -2340,13 +2340,13 @@ __metadata:
|
||||
linkType: hard
|
||||
|
||||
"js-yaml@npm:^4.1.0":
|
||||
version: 4.1.0
|
||||
resolution: "js-yaml@npm:4.1.0"
|
||||
version: 4.1.1
|
||||
resolution: "js-yaml@npm:4.1.1"
|
||||
dependencies:
|
||||
argparse: "npm:^2.0.1"
|
||||
bin:
|
||||
js-yaml: bin/js-yaml.js
|
||||
checksum: 10c0/184a24b4eaacfce40ad9074c64fd42ac83cf74d8c8cd137718d456ced75051229e5061b8633c3366b8aada17945a7a356b337828c19da92b51ae62126575018f
|
||||
checksum: 10c0/561c7d7088c40a9bb53cc75becbfb1df6ae49b34b5e6e5a81744b14ae8667ec564ad2527709d1a6e7d5e5fa6d483aa0f373a50ad98d42fde368ec4a190d4fae7
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
|
||||
@@ -329,8 +329,9 @@ class SqliteSaver(BaseCheckpointSaver[str]):
|
||||
FROM checkpoints
|
||||
{where}
|
||||
ORDER BY checkpoint_id DESC"""
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
if limit is not None:
|
||||
query += " LIMIT ?"
|
||||
param_values = (*param_values, limit)
|
||||
with self.cursor(transaction=False) as cur, closing(self.conn.cursor()) as wcur:
|
||||
cur.execute(query, param_values)
|
||||
for (
|
||||
|
||||
@@ -425,8 +425,9 @@ class AsyncSqliteSaver(BaseCheckpointSaver[str]):
|
||||
FROM checkpoints
|
||||
{where}
|
||||
ORDER BY checkpoint_id DESC"""
|
||||
if limit:
|
||||
query += f" LIMIT {limit}"
|
||||
if limit is not None:
|
||||
query += " LIMIT ?"
|
||||
params = (*params, limit)
|
||||
async with (
|
||||
self.lock,
|
||||
self.conn.execute(query, params) as cur,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
@@ -8,6 +9,23 @@ from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.checkpoint.base import get_checkpoint_id
|
||||
|
||||
|
||||
def _validate_filter_key(key: str) -> None:
|
||||
"""Validate that a filter key is safe for use in SQL queries.
|
||||
|
||||
Args:
|
||||
key: The filter key to validate
|
||||
|
||||
Raises:
|
||||
ValueError: If the key contains invalid characters that could enable SQL injection
|
||||
"""
|
||||
# Allow alphanumeric characters, underscores, dots, and hyphens
|
||||
# This covers typical JSON property names while preventing SQL injection
|
||||
if not re.match(r"^[a-zA-Z0-9_.-]+$", key):
|
||||
raise ValueError(
|
||||
f"Invalid filter key: '{key}'. Filter keys must contain only alphanumeric characters, underscores, dots, and hyphens."
|
||||
)
|
||||
|
||||
|
||||
def _metadata_predicate(
|
||||
metadata_filter: dict[str, Any],
|
||||
) -> tuple[Sequence[str], Sequence[Any]]:
|
||||
@@ -43,6 +61,7 @@ def _metadata_predicate(
|
||||
|
||||
# process metadata query
|
||||
for query_key, query_value in metadata_filter.items():
|
||||
_validate_filter_key(query_key)
|
||||
operator, param_value = _where_value(query_value)
|
||||
predicates.append(
|
||||
f"json_extract(CAST(metadata AS TEXT), '$.{query_key}') {operator}"
|
||||
|
||||
@@ -404,12 +404,9 @@ class BaseSqliteStore:
|
||||
# SQLite json_extract returns unquoted string values
|
||||
if isinstance(value, str):
|
||||
filter_conditions.append(
|
||||
"json_extract(value, '$."
|
||||
+ key
|
||||
+ "') = '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'"
|
||||
"json_extract(value, '$." + key + "') = ?"
|
||||
)
|
||||
filter_params.append(value)
|
||||
elif value is None:
|
||||
filter_conditions.append(
|
||||
"json_extract(value, '$." + key + "') IS NULL"
|
||||
@@ -423,9 +420,11 @@ class BaseSqliteStore:
|
||||
+ ("1" if value else "0")
|
||||
)
|
||||
elif isinstance(value, (int, float)):
|
||||
# Use parameterized query to handle special floats and large integers
|
||||
filter_conditions.append(
|
||||
"json_extract(value, '$." + key + "') = " + str(value)
|
||||
"json_extract(value, '$." + key + "') = ?"
|
||||
)
|
||||
filter_params.append(float(value))
|
||||
else:
|
||||
# Complex objects (list, dict, …) – compare JSON text
|
||||
filter_conditions.append(
|
||||
@@ -636,85 +635,66 @@ class BaseSqliteStore:
|
||||
# We need to properly format values for SQLite JSON extraction comparison
|
||||
if op == "$eq":
|
||||
if isinstance(value, str):
|
||||
# Direct string comparison with proper quoting for unquoted json_extract result
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') = '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') = ?", [value]
|
||||
elif value is None:
|
||||
return f"json_extract(value, '$.{key}') IS NULL", []
|
||||
elif isinstance(value, bool):
|
||||
# SQLite JSON stores booleans as integers
|
||||
return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
|
||||
elif isinstance(value, (int, float)):
|
||||
return f"json_extract(value, '$.{key}') = {value}", []
|
||||
# Convert to float to handle inf, -inf, nan, and very large integers
|
||||
# SQLite REAL can handle these cases better than INTEGER
|
||||
return f"json_extract(value, '$.{key}') = ?", [float(value)]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
|
||||
elif op == "$gt":
|
||||
# For numeric values, SQLite needs to compare as numbers, not strings
|
||||
if isinstance(value, (int, float)):
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) > {value}", []
|
||||
# Convert to float to handle special values and very large integers
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
|
||||
float(value)
|
||||
]
|
||||
elif isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') > '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') > ?", [value]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
|
||||
elif op == "$gte":
|
||||
if isinstance(value, (int, float)):
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= {value}", []
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
|
||||
float(value)
|
||||
]
|
||||
elif isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') >= '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') >= ?", [value]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
|
||||
elif op == "$lt":
|
||||
if isinstance(value, (int, float)):
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) < {value}", []
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
|
||||
float(value)
|
||||
]
|
||||
elif isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') < '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') < ?", [value]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
|
||||
elif op == "$lte":
|
||||
if isinstance(value, (int, float)):
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= {value}", []
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
|
||||
float(value)
|
||||
]
|
||||
elif isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') <= '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') <= ?", [value]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
|
||||
elif op == "$ne":
|
||||
if isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') != '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') != ?", [value]
|
||||
elif value is None:
|
||||
return f"json_extract(value, '$.{key}') IS NOT NULL", []
|
||||
elif isinstance(value, bool):
|
||||
return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
|
||||
elif isinstance(value, (int, float)):
|
||||
return f"json_extract(value, '$.{key}') != {value}", []
|
||||
# Convert to float for consistency
|
||||
return f"json_extract(value, '$.{key}') != ?", [float(value)]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
|
||||
else:
|
||||
@@ -874,85 +854,66 @@ class SqliteStore(BaseSqliteStore, BaseStore):
|
||||
# We need to properly format values for SQLite JSON extraction comparison
|
||||
if op == "$eq":
|
||||
if isinstance(value, str):
|
||||
# Direct string comparison with proper quoting for unquoted json_extract result
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') = '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') = ?", [value]
|
||||
elif value is None:
|
||||
return f"json_extract(value, '$.{key}') IS NULL", []
|
||||
elif isinstance(value, bool):
|
||||
# SQLite JSON stores booleans as integers
|
||||
return f"json_extract(value, '$.{key}') = {1 if value else 0}", []
|
||||
elif isinstance(value, (int, float)):
|
||||
return f"json_extract(value, '$.{key}') = {value}", []
|
||||
# Convert to float to handle inf, -inf, nan, and very large integers
|
||||
# SQLite REAL can handle these cases better than INTEGER
|
||||
return f"json_extract(value, '$.{key}') = ?", [float(value)]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') = ?", [orjson.dumps(value)]
|
||||
elif op == "$gt":
|
||||
# For numeric values, SQLite needs to compare as numbers, not strings
|
||||
if isinstance(value, (int, float)):
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) > {value}", []
|
||||
# Convert to float to handle special values and very large integers
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) > ?", [
|
||||
float(value)
|
||||
]
|
||||
elif isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') > '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') > ?", [value]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') > ?", [orjson.dumps(value)]
|
||||
elif op == "$gte":
|
||||
if isinstance(value, (int, float)):
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= {value}", []
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) >= ?", [
|
||||
float(value)
|
||||
]
|
||||
elif isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') >= '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') >= ?", [value]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') >= ?", [orjson.dumps(value)]
|
||||
elif op == "$lt":
|
||||
if isinstance(value, (int, float)):
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) < {value}", []
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) < ?", [
|
||||
float(value)
|
||||
]
|
||||
elif isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') < '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') < ?", [value]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') < ?", [orjson.dumps(value)]
|
||||
elif op == "$lte":
|
||||
if isinstance(value, (int, float)):
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= {value}", []
|
||||
return f"CAST(json_extract(value, '$.{key}') AS REAL) <= ?", [
|
||||
float(value)
|
||||
]
|
||||
elif isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') <= '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') <= ?", [value]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') <= ?", [orjson.dumps(value)]
|
||||
elif op == "$ne":
|
||||
if isinstance(value, str):
|
||||
return (
|
||||
f"json_extract(value, '$.{key}') != '"
|
||||
+ value.replace("'", "''")
|
||||
+ "'",
|
||||
[],
|
||||
)
|
||||
return f"json_extract(value, '$.{key}') != ?", [value]
|
||||
elif value is None:
|
||||
return f"json_extract(value, '$.{key}') IS NOT NULL", []
|
||||
elif isinstance(value, bool):
|
||||
return f"json_extract(value, '$.{key}') != {1 if value else 0}", []
|
||||
elif isinstance(value, (int, float)):
|
||||
return f"json_extract(value, '$.{key}') != {value}", []
|
||||
# Convert to float for consistency
|
||||
return f"json_extract(value, '$.{key}') != ?", [float(value)]
|
||||
else:
|
||||
return f"json_extract(value, '$.{key}') != ?", [orjson.dumps(value)]
|
||||
else:
|
||||
|
||||
@@ -113,4 +113,78 @@ class TestAsyncSqliteSaver:
|
||||
search_results_5[1].config["configurable"]["checkpoint_ns"],
|
||||
} == {"", "inner"}
|
||||
|
||||
# TODO: test before and limit params
|
||||
# Test limit param
|
||||
search_results_6 = [
|
||||
c
|
||||
async for c in saver.alist(
|
||||
{"configurable": {"thread_id": "thread-2"}}, limit=1
|
||||
)
|
||||
]
|
||||
assert len(search_results_6) == 1
|
||||
assert search_results_6[0].config["configurable"]["thread_id"] == "thread-2"
|
||||
|
||||
# Test before param
|
||||
search_results_7 = [
|
||||
c async for c in saver.alist(None, before=search_results_5[1].config)
|
||||
]
|
||||
assert len(search_results_7) == 1
|
||||
assert search_results_7[0].config["configurable"]["thread_id"] == "thread-1"
|
||||
|
||||
async def test_limit_parameter_sql_injection_prevention(self) -> None:
|
||||
"""Test that the limit parameter properly uses parameterized queries to prevent SQL injection."""
|
||||
async with AsyncSqliteSaver.from_conn_string(":memory:") as saver:
|
||||
# Setup: Create multiple checkpoints
|
||||
for i in range(5):
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": f"thread-{i}",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
checkpoint = empty_checkpoint()
|
||||
metadata: CheckpointMetadata = {"index": i}
|
||||
await saver.aput(config, checkpoint, metadata, {})
|
||||
|
||||
# Test that limit works correctly with valid integer
|
||||
results = [c async for c in saver.alist(None, limit=2)]
|
||||
assert len(results) == 2
|
||||
|
||||
# Test that limit=0 returns no results
|
||||
results = [c async for c in saver.alist(None, limit=0)]
|
||||
assert len(results) == 0
|
||||
|
||||
# Test that limit=None returns all results
|
||||
results = [c async for c in saver.alist(None, limit=None)]
|
||||
assert len(results) == 5
|
||||
|
||||
# Test explicit SQL injection attempt via limit parameter
|
||||
# Even if type checking is bypassed and a malicious string is passed,
|
||||
# the parameterized query will treat it as a value, not SQL code
|
||||
# This would cause an error (can't convert string to int for LIMIT),
|
||||
# which is the correct secure behavior
|
||||
malicious_limits = [
|
||||
"1; DROP TABLE checkpoints; --",
|
||||
"1 OR 1=1",
|
||||
"999999 UNION SELECT * FROM checkpoints",
|
||||
]
|
||||
|
||||
for malicious_limit in malicious_limits:
|
||||
# The parameterized query should safely reject non-integer limits
|
||||
# or convert them in a way that prevents SQL injection
|
||||
try:
|
||||
# Bypass type checking by casting
|
||||
results = [
|
||||
c
|
||||
async for c in saver.alist(None, limit=malicious_limit) # type: ignore
|
||||
]
|
||||
# If it doesn't raise an error, it should at least not execute the injection
|
||||
# SQLite's parameter binding will try to convert the string to an integer
|
||||
# which will either fail or treat it as 0
|
||||
except Exception:
|
||||
# Expected: SQLite should reject invalid limit values
|
||||
pass
|
||||
|
||||
# Verify the checkpoints table still exists and has all data
|
||||
# (would have been dropped if injection succeeded)
|
||||
results = [c async for c in saver.alist(None, limit=None)]
|
||||
assert len(results) == 5
|
||||
|
||||
@@ -182,3 +182,120 @@ class TestSqliteSaver:
|
||||
with pytest.raises(NotImplementedError, match="AsyncSqliteSaver"):
|
||||
async for _ in saver.alist(self.config_1):
|
||||
pass
|
||||
|
||||
def test_metadata_predicate_sql_injection_prevention(self) -> None:
|
||||
"""Test that _metadata_predicate rejects malicious filter keys."""
|
||||
# Test various SQL injection payloads
|
||||
malicious_keys = [
|
||||
"x') OR '1'='1", # Boolean-based injection
|
||||
"x') OR 1=1 --", # Comment-based injection
|
||||
"x') UNION SELECT 1,2,3,4,5,6,7 --", # UNION-based injection
|
||||
"access') = 'public' OR '1'='1' OR json_extract(value, '$.", # Complex injection
|
||||
"'; DROP TABLE checkpoints; --", # Destructive injection
|
||||
]
|
||||
|
||||
for malicious_key in malicious_keys:
|
||||
with pytest.raises(ValueError, match="Invalid filter key"):
|
||||
_metadata_predicate({malicious_key: "dummy"})
|
||||
|
||||
def test_checkpoint_search_sql_injection_prevention(self) -> None:
|
||||
"""Test that SQL injection via malicious filter keys is prevented in checkpoint search."""
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
# Setup: Create checkpoints with different metadata
|
||||
config_public: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-public",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
config_private: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": "thread-private",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
|
||||
checkpoint_public = empty_checkpoint()
|
||||
checkpoint_private = empty_checkpoint()
|
||||
|
||||
metadata_public: CheckpointMetadata = {
|
||||
"access": "public",
|
||||
"data": "public information",
|
||||
}
|
||||
metadata_private: CheckpointMetadata = {
|
||||
"access": "private",
|
||||
"data": "secret information",
|
||||
"password": "secret123",
|
||||
}
|
||||
|
||||
saver.put(config_public, checkpoint_public, metadata_public, {})
|
||||
saver.put(config_private, checkpoint_private, metadata_private, {})
|
||||
|
||||
# Normal query - should return only public checkpoint
|
||||
normal_results = list(saver.list(None, filter={"access": "public"}))
|
||||
assert len(normal_results) == 1
|
||||
assert normal_results[0].metadata["access"] == "public"
|
||||
|
||||
# SQL injection attempt should raise ValueError
|
||||
malicious_key = (
|
||||
"access') = 'public' OR '1'='1' OR json_extract(metadata, '$."
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid filter key"):
|
||||
list(saver.list(None, filter={malicious_key: "dummy"}))
|
||||
|
||||
def test_limit_parameter_sql_injection_prevention(self) -> None:
|
||||
"""Test that the limit parameter properly uses parameterized queries to prevent SQL injection."""
|
||||
with SqliteSaver.from_conn_string(":memory:") as saver:
|
||||
# Setup: Create multiple checkpoints
|
||||
for i in range(5):
|
||||
config: RunnableConfig = {
|
||||
"configurable": {
|
||||
"thread_id": f"thread-{i}",
|
||||
"checkpoint_ns": "",
|
||||
}
|
||||
}
|
||||
checkpoint = empty_checkpoint()
|
||||
metadata: CheckpointMetadata = {"index": i}
|
||||
saver.put(config, checkpoint, metadata, {})
|
||||
|
||||
# Test that limit works correctly with valid integer
|
||||
results = list(saver.list(None, limit=2))
|
||||
assert len(results) == 2
|
||||
|
||||
# Test that limit=0 returns no results
|
||||
results = list(saver.list(None, limit=0))
|
||||
assert len(results) == 0
|
||||
|
||||
# Test that limit=None returns all results
|
||||
results = list(saver.list(None, limit=None))
|
||||
assert len(results) == 5
|
||||
|
||||
# Test explicit SQL injection attempt via limit parameter
|
||||
# Even if type checking is bypassed and a malicious string is passed,
|
||||
# the parameterized query will treat it as a value, not SQL code
|
||||
# This would cause an error (can't convert string to int for LIMIT),
|
||||
# which is the correct secure behavior
|
||||
malicious_limits = [
|
||||
"1; DROP TABLE checkpoints; --",
|
||||
"1 OR 1=1",
|
||||
"999999 UNION SELECT * FROM checkpoints",
|
||||
]
|
||||
|
||||
for malicious_limit in malicious_limits:
|
||||
# The parameterized query should safely reject non-integer limits
|
||||
# or convert them in a way that prevents SQL injection
|
||||
try:
|
||||
# Bypass type checking by casting
|
||||
results = list(saver.list(None, limit=malicious_limit)) # type: ignore
|
||||
# If it doesn't raise an error, it should at least not execute the injection
|
||||
# SQLite's parameter binding will try to convert the string to an integer
|
||||
# which will either fail or treat it as 0
|
||||
except Exception:
|
||||
# Expected: SQLite should reject invalid limit values
|
||||
pass
|
||||
|
||||
# Verify the checkpoints table still exists and has all data
|
||||
# (would have been dropped if injection succeeded)
|
||||
results = list(saver.list(None, limit=None))
|
||||
assert len(results) == 5
|
||||
|
||||
@@ -1069,6 +1069,110 @@ def test_sql_injection_vulnerability(store: SqliteStore) -> None:
|
||||
store.search(("docs",), filter={malicious_key: "dummy"})
|
||||
|
||||
|
||||
def test_sql_injection_filter_values(store: SqliteStore) -> None:
|
||||
"""Test that SQL injection via malicious filter values is properly escaped."""
|
||||
# Setup: Create documents with different access levels
|
||||
store.put(("docs",), "doc1", {"access": "public", "title": "Public Document"})
|
||||
store.put(("docs",), "doc2", {"access": "private", "title": "Private Document"})
|
||||
store.put(("docs",), "doc3", {"access": "secret", "title": "Secret Document"})
|
||||
|
||||
# Test 1: Basic SQL injection attempt with single quote
|
||||
malicious_value = "public' OR '1'='1"
|
||||
results = store.search(("docs",), filter={"access": malicious_value})
|
||||
# Should return 0 results because the malicious value is escaped and won't match anything
|
||||
assert len(results) == 0, "SQL injection via string value should be blocked"
|
||||
|
||||
# Test 2: SQL injection with comment
|
||||
malicious_value = "public'; --"
|
||||
results = store.search(("docs",), filter={"access": malicious_value})
|
||||
assert len(results) == 0, "SQL comment injection should be blocked"
|
||||
|
||||
# Test 3: UNION injection attempt
|
||||
malicious_value = "public' UNION SELECT * FROM store --"
|
||||
results = store.search(("docs",), filter={"access": malicious_value})
|
||||
assert len(results) == 0, "UNION injection should be blocked"
|
||||
|
||||
# Test 4: Parameterized queries handle strings with null bytes and SQL injection attempts safely
|
||||
malicious_value = "public\x00' OR '1'='1"
|
||||
results = store.search(("docs",), filter={"access": malicious_value})
|
||||
assert len(results) == 0, (
|
||||
"Parameterized queries treat injection attempts as literal strings"
|
||||
)
|
||||
|
||||
# Test 5: Multiple single quotes
|
||||
malicious_value = "''''"
|
||||
results = store.search(("docs",), filter={"access": malicious_value})
|
||||
assert len(results) == 0, "Multiple quotes should be handled safely"
|
||||
|
||||
# Test 6: Legitimate value with single quote should work
|
||||
store.put(("docs",), "doc4", {"title": "O'Brien's Document", "access": "public"})
|
||||
results = store.search(("docs",), filter={"title": "O'Brien's Document"})
|
||||
assert len(results) == 1, "Legitimate single quotes should work"
|
||||
assert results[0].value["title"] == "O'Brien's Document"
|
||||
|
||||
# Test 7: Unicode characters with injection attempt
|
||||
malicious_value = "public' OR 'א'='א"
|
||||
results = store.search(("docs",), filter={"access": malicious_value})
|
||||
assert len(results) == 0, "Unicode-based injection should be blocked"
|
||||
|
||||
|
||||
def test_numeric_filter_safety(store: SqliteStore) -> None:
|
||||
"""Test that numeric filter values are handled safely."""
|
||||
# Setup: Create documents with numeric fields
|
||||
store.put(("items",), "item1", {"price": 10, "quantity": 5})
|
||||
store.put(("items",), "item2", {"price": 20, "quantity": 3})
|
||||
store.put(("items",), "item3", {"price": 30, "quantity": 1})
|
||||
|
||||
# Test 1: Normal numeric comparison
|
||||
results = store.search(("items",), filter={"price": {"$gt": 15}})
|
||||
assert len(results) == 2
|
||||
assert all(r.value["price"] > 15 for r in results)
|
||||
|
||||
# Test 2: Special float values (infinity)
|
||||
results = store.search(("items",), filter={"price": {"$lt": float("inf")}})
|
||||
assert len(results) == 3, "All finite values should be less than infinity"
|
||||
|
||||
# Test 3: Special float values (negative infinity)
|
||||
results = store.search(("items",), filter={"price": {"$gt": float("-inf")}})
|
||||
assert len(results) == 3, (
|
||||
"All finite values should be greater than negative infinity"
|
||||
)
|
||||
|
||||
# Test 4: NaN handling - NaN comparisons should not cause errors
|
||||
try:
|
||||
results = store.search(("items",), filter={"price": {"$eq": float("nan")}})
|
||||
# NaN never equals anything, including itself, so should return 0 results
|
||||
assert len(results) == 0
|
||||
except Exception as e:
|
||||
pytest.fail(f"NaN handling should not raise exception: {e}")
|
||||
|
||||
# Test 5: Very large numbers
|
||||
results = store.search(("items",), filter={"price": {"$lt": 10**100}})
|
||||
assert len(results) == 3, "Very large numbers should be handled safely"
|
||||
|
||||
# Test 6: Negative numbers
|
||||
store.put(("items",), "item4", {"price": -10, "quantity": 0})
|
||||
results = store.search(("items",), filter={"price": {"$lt": 0}})
|
||||
assert len(results) == 1
|
||||
assert results[0].key == "item4"
|
||||
|
||||
|
||||
def test_boolean_filter_safety(store: SqliteStore) -> None:
|
||||
"""Test that boolean filter values are handled safely."""
|
||||
store.put(("flags",), "flag1", {"active": True, "name": "Feature A"})
|
||||
store.put(("flags",), "flag2", {"active": False, "name": "Feature B"})
|
||||
store.put(("flags",), "flag3", {"active": True, "name": "Feature C"})
|
||||
|
||||
# Test boolean filters
|
||||
results = store.search(("flags",), filter={"active": True})
|
||||
assert len(results) == 2
|
||||
assert all(r.value["active"] is True for r in results)
|
||||
|
||||
results = store.search(("flags",), filter={"active": False})
|
||||
assert len(results) == 1
|
||||
assert results[0].value["active"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("distance_type", VECTOR_TYPES)
|
||||
def test_non_ascii(
|
||||
fake_embeddings: CharacterEmbeddings,
|
||||
|
||||
@@ -97,7 +97,8 @@ class InMemorySaver(
|
||||
self.stack.enter_context(self.blobs) # type: ignore[arg-type]
|
||||
|
||||
def __enter__(self) -> InMemorySaver:
|
||||
return self.stack.__enter__()
|
||||
self.stack.__enter__()
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
@@ -108,7 +109,8 @@ class InMemorySaver(
|
||||
return self.stack.__exit__(exc_type, exc_value, traceback)
|
||||
|
||||
async def __aenter__(self) -> InMemorySaver:
|
||||
return self.stack.__enter__()
|
||||
self.stack.__enter__()
|
||||
return self
|
||||
|
||||
async def __aexit__(
|
||||
self,
|
||||
|
||||
@@ -15,9 +15,7 @@ class UntypedSerializerProtocol(Protocol):
|
||||
class SerializerProtocol(Protocol):
|
||||
"""Protocol for serialization and deserialization of objects.
|
||||
|
||||
- `dumps`: Serialize an object to bytes.
|
||||
- `dumps_typed`: Serialize an object to a tuple `(type, bytes)`.
|
||||
- `loads`: Deserialize an object from bytes.
|
||||
- `loads_typed`: Deserialize an object from a tuple `(type, bytes)`.
|
||||
|
||||
Valid implementations include the `pickle`, `json` and `orjson` modules.
|
||||
|
||||
@@ -188,7 +188,14 @@ class TestMemorySaver:
|
||||
assert len(search_results_4) == 0
|
||||
|
||||
|
||||
def test_memory_saver() -> None:
|
||||
async def test_memory_saver() -> None:
|
||||
from langgraph.checkpoint.memory import InMemorySaver
|
||||
|
||||
assert isinstance(InMemorySaver(), InMemorySaver)
|
||||
memory_saver = InMemorySaver()
|
||||
assert isinstance(memory_saver, InMemorySaver)
|
||||
|
||||
async with memory_saver as async_memory_saver:
|
||||
assert async_memory_saver is memory_saver
|
||||
|
||||
with memory_saver as sync_memory_saver:
|
||||
assert sync_memory_saver is memory_saver
|
||||
|
||||
@@ -15,6 +15,7 @@ import msgspec
|
||||
|
||||
from langgraph_cli.schemas import (
|
||||
AuthConfig,
|
||||
CacheConfig,
|
||||
CheckpointerConfig,
|
||||
Config,
|
||||
ConfigurableHeaderConfig,
|
||||
@@ -111,6 +112,7 @@ def add_descriptions_to_schema(schema, cls):
|
||||
SecurityConfig,
|
||||
HttpConfig,
|
||||
CorsConfig,
|
||||
CacheConfig,
|
||||
ThreadTTLConfig,
|
||||
CheckpointerConfig,
|
||||
SerdeConfig,
|
||||
|
||||
@@ -154,6 +154,7 @@ def validate_config(config: Config) -> Config:
|
||||
"env": config.get("env", {}),
|
||||
"store": config.get("store"),
|
||||
"auth": config.get("auth"),
|
||||
"encryption": config.get("encryption"),
|
||||
"http": config.get("http"),
|
||||
"checkpointer": config.get("checkpointer"),
|
||||
"ui": config.get("ui"),
|
||||
@@ -228,6 +229,14 @@ def validate_config(config: Config) -> Config:
|
||||
f"Invalid auth.path format: '{auth_conf['path']}'. "
|
||||
"Must be in format './path/to/file.py:attribute_name'"
|
||||
)
|
||||
# Validate encryption config
|
||||
if encryption_conf := config.get("encryption"):
|
||||
if "path" in encryption_conf:
|
||||
if ":" not in encryption_conf["path"]:
|
||||
raise ValueError(
|
||||
f"Invalid encryption.path format: '{encryption_conf['path']}'. "
|
||||
"Must be in format './path/to/file.py:attribute_name'"
|
||||
)
|
||||
if http_conf := config.get("http"):
|
||||
if "app" in http_conf:
|
||||
if ":" not in http_conf["app"]:
|
||||
@@ -614,6 +623,49 @@ def _update_auth_path(
|
||||
)
|
||||
|
||||
|
||||
def _update_encryption_path(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
"""Update encryption.path to use Docker container paths."""
|
||||
encryption_conf = config.get("encryption")
|
||||
if not encryption_conf or not (path_str := encryption_conf.get("path")):
|
||||
return
|
||||
|
||||
module_str, sep, attr_str = path_str.partition(":")
|
||||
if not sep or not module_str.startswith("."):
|
||||
return # Already validated or absolute path
|
||||
|
||||
resolved = config_path.parent / module_str
|
||||
if not resolved.exists():
|
||||
raise FileNotFoundError(
|
||||
f"Encryption file not found: {resolved} (from {path_str})"
|
||||
)
|
||||
if not resolved.is_file():
|
||||
raise IsADirectoryError(f"Encryption path must be a file: {resolved}")
|
||||
|
||||
# Check faux packages first (higher priority)
|
||||
for faux_path, (_, destpath) in local_deps.faux_pkgs.items():
|
||||
if resolved.is_relative_to(faux_path):
|
||||
new_path = f"{destpath}/{resolved.relative_to(faux_path)}:{attr_str}"
|
||||
encryption_conf["path"] = new_path
|
||||
return
|
||||
|
||||
# Check real packages
|
||||
for real_path in local_deps.real_pkgs:
|
||||
if resolved.is_relative_to(real_path):
|
||||
new_path = (
|
||||
f"/deps/{real_path.name}/{resolved.relative_to(real_path)}:{attr_str}"
|
||||
)
|
||||
encryption_conf["path"] = new_path
|
||||
return
|
||||
|
||||
raise ValueError(
|
||||
f"Encryption file '{resolved}' not covered by dependencies.\n"
|
||||
"Add its parent directory to the 'dependencies' array in your config.\n"
|
||||
f"Current dependencies: {config['dependencies']}"
|
||||
)
|
||||
|
||||
|
||||
def _update_http_app_path(
|
||||
config_path: pathlib.Path, config: Config, local_deps: LocalDeps
|
||||
) -> None:
|
||||
@@ -809,6 +861,8 @@ def python_config_to_docker(
|
||||
_update_graph_paths(config_path, config, local_deps)
|
||||
# Rewrite auth path, so it points to the correct location in the Docker container
|
||||
_update_auth_path(config_path, config, local_deps)
|
||||
# Rewrite encryption path, so it points to the correct location in the Docker container
|
||||
_update_encryption_path(config_path, config, local_deps)
|
||||
# Rewrite HTTP app path, so it points to the correct location in the Docker container
|
||||
_update_http_app_path(config_path, config, local_deps)
|
||||
|
||||
@@ -899,6 +953,9 @@ ADD {relpath} /deps/{name}
|
||||
if (auth_config := config.get("auth")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
|
||||
|
||||
if (encryption_config := config.get("encryption")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
|
||||
|
||||
if (http_config := config.get("http")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
|
||||
|
||||
@@ -1022,6 +1079,9 @@ def node_config_to_docker(
|
||||
if (auth_config := config.get("auth")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_AUTH='{json.dumps(auth_config)}'")
|
||||
|
||||
if (encryption_config := config.get("encryption")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_ENCRYPTION='{json.dumps(encryption_config)}'")
|
||||
|
||||
if (http_config := config.get("http")) is not None:
|
||||
env_vars.append(f"ENV LANGGRAPH_HTTP='{json.dumps(http_config)}'")
|
||||
|
||||
|
||||
@@ -233,6 +233,27 @@ class SecurityConfig(TypedDict, total=False):
|
||||
"""
|
||||
|
||||
|
||||
class CacheConfig(TypedDict, total=False):
|
||||
cache_keys: list[str]
|
||||
"""Optional. List of header keys to use for caching.
|
||||
|
||||
Example:
|
||||
["user_id", "workspace_id"]
|
||||
"""
|
||||
ttl_seconds: int
|
||||
"""Optional. Time-to-live in seconds for cached items.
|
||||
|
||||
Example:
|
||||
3600
|
||||
"""
|
||||
max_size: int
|
||||
"""Optional. Maximum size of the cache.
|
||||
|
||||
Example:
|
||||
100
|
||||
"""
|
||||
|
||||
|
||||
class AuthConfig(TypedDict, total=False):
|
||||
"""Configuration for custom authentication logic and how it integrates into the OpenAPI spec."""
|
||||
|
||||
@@ -269,6 +290,37 @@ class AuthConfig(TypedDict, total=False):
|
||||
]
|
||||
}
|
||||
"""
|
||||
cache: CacheConfig
|
||||
"""Optional. Cache configuration for the server.
|
||||
|
||||
Example:
|
||||
{
|
||||
"cache_keys": ["user_id", "workspace_id"],
|
||||
"ttl_seconds": 3600,
|
||||
"max_size": 100
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class EncryptionConfig(TypedDict, total=False):
|
||||
"""Configuration for custom at-rest encryption logic.
|
||||
|
||||
Allows you to implement custom encryption for sensitive data stored in the database,
|
||||
including metadata fields and checkpoint blobs.
|
||||
"""
|
||||
|
||||
path: str
|
||||
"""Required. Path to an instance of the Encryption() class that implements custom encryption handlers.
|
||||
|
||||
Format: "path/to/file.py:my_encryption"
|
||||
|
||||
Example:
|
||||
{
|
||||
"encryption": {
|
||||
"path": "./encryption.py:my_encryption"
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
class CorsConfig(TypedDict, total=False):
|
||||
@@ -367,7 +419,12 @@ class HttpConfig(TypedDict, total=False):
|
||||
Default is False.
|
||||
"""
|
||||
disable_mcp: bool
|
||||
"""Optional. If `True`, /mcp routes are removed, disabling the MCP server.
|
||||
"""Optional. If `True`, /mcp routes are removed, disabling default support to expose the deployment as an MCP server.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_a2a: bool
|
||||
"""Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
@@ -377,6 +434,17 @@ class HttpConfig(TypedDict, total=False):
|
||||
Set to True to disable the following endpoints: /openapi.json, /info, /metrics, /docs.
|
||||
This will also make the /ok endpoint skip any DB or other checks, always returning {"ok": True}.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_ui: bool
|
||||
"""Optional. If `True`, /ui routes are removed, disabling the UI server.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
disable_webhooks: bool
|
||||
"""Optional. If `True`, webhooks are disabled. Runs created with an associated webhook will
|
||||
still be executed, but the webhook event will not be sent.
|
||||
|
||||
Default is False.
|
||||
"""
|
||||
cors: CorsConfig | None
|
||||
@@ -409,6 +477,12 @@ class HttpConfig(TypedDict, total=False):
|
||||
Default is False. This flag only affects authentication behavior
|
||||
if `app` is provided and contains custom routes.
|
||||
"""
|
||||
mount_prefix: str
|
||||
"""Optional. URL prefix to prepend to all the routes.
|
||||
|
||||
Example:
|
||||
"/api"
|
||||
"""
|
||||
|
||||
|
||||
class Config(TypedDict, total=False):
|
||||
@@ -524,10 +598,16 @@ class Config(TypedDict, total=False):
|
||||
"""
|
||||
|
||||
auth: AuthConfig | None
|
||||
"""Optional. Custom authentication config, including the path to your Python auth logic and
|
||||
"""Optional. Custom authentication config, including the path to your Python auth logic and
|
||||
the OpenAPI security definitions it uses.
|
||||
"""
|
||||
|
||||
encryption: EncryptionConfig | None
|
||||
"""Optional. Custom at-rest encryption config, including the path to your Python encryption logic.
|
||||
|
||||
Allows you to implement custom encryption for sensitive data stored in the database.
|
||||
"""
|
||||
|
||||
http: HttpConfig | None
|
||||
"""Optional. Configuration for the built-in HTTP server, controlling which custom routes are exposed
|
||||
and how cross-origin requests are handled.
|
||||
@@ -550,6 +630,7 @@ __all__ = [
|
||||
"StoreConfig",
|
||||
"CheckpointerConfig",
|
||||
"AuthConfig",
|
||||
"EncryptionConfig",
|
||||
"HttpConfig",
|
||||
"MiddlewareOrders",
|
||||
"Distros",
|
||||
|
||||
@@ -99,6 +99,17 @@
|
||||
},
|
||||
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
|
||||
},
|
||||
"encryption": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/EncryptionConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
|
||||
},
|
||||
"env": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -292,6 +303,17 @@
|
||||
},
|
||||
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
|
||||
},
|
||||
"encryption": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/EncryptionConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
|
||||
},
|
||||
"env": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -405,6 +427,10 @@
|
||||
"description": "Configuration for custom authentication logic and how it integrates into the OpenAPI spec.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache": {
|
||||
"$ref": "#/$defs/CacheConfig",
|
||||
"description": "Optional. Cache configuration for the server.\n"
|
||||
},
|
||||
"disable_studio_auth": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.\n\nDefaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header\nvalue is a valid API key for the deployment's workspace. If `True`, all requests will go through your custom\nauthentication logic, regardless of origin of the request.\n"
|
||||
@@ -420,6 +446,29 @@
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"CacheConfig": {
|
||||
"title": "CacheConfig",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Optional. List of header keys to use for caching.\n"
|
||||
},
|
||||
"max_size": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Maximum size of the cache.\n"
|
||||
},
|
||||
"ttl_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Time-to-live in seconds for cached items.\n"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
|
||||
},
|
||||
"SecurityConfig": {
|
||||
"title": "SecurityConfig",
|
||||
"description": "Configuration for OpenAPI security definitions and requirements.\n\nUseful for specifying global or path-level authentication and authorization flows\n(e.g., OAuth2, API key headers, etc.).",
|
||||
@@ -565,6 +614,17 @@
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"EncryptionConfig": {
|
||||
"title": "EncryptionConfig",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"HttpConfig": {
|
||||
"title": "HttpConfig",
|
||||
"description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",
|
||||
@@ -596,13 +656,17 @@
|
||||
],
|
||||
"description": "Optional. Defines CORS restrictions. If omitted, no special rules are set and\ncross-origin behavior depends on default server settings.\n"
|
||||
},
|
||||
"disable_a2a": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.\n\nDefault is False.\n"
|
||||
},
|
||||
"disable_assistants": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /assistants routes are removed from the server.\n\nDefault is False (meaning /assistants is enabled).\n"
|
||||
},
|
||||
"disable_mcp": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /mcp routes are removed, disabling the MCP server.\n\nDefault is False.\n"
|
||||
"description": "Optional. If `True`, /mcp routes are removed, disabling default support to expose the deployment as an MCP server.\n\nDefault is False.\n"
|
||||
},
|
||||
"disable_meta": {
|
||||
"type": "boolean",
|
||||
@@ -620,6 +684,14 @@
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /threads routes are removed.\n\nDefault is False.\n"
|
||||
},
|
||||
"disable_ui": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /ui routes are removed, disabling the UI server.\n\nDefault is False.\n"
|
||||
},
|
||||
"disable_webhooks": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, webhooks are disabled. Runs created with an associated webhook will\nstill be executed, but the webhook event will not be sent.\n\nDefault is False.\n"
|
||||
},
|
||||
"enable_custom_route_auth": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, authentication is enabled for custom routes,\nnot just the routes that are protected by default.\n(Routes protected by default include /assistants, /threads, and /runs).\n\nDefault is False. This flag only affects authentication behavior\nif `app` is provided and contains custom routes.\n"
|
||||
@@ -648,6 +720,10 @@
|
||||
}
|
||||
],
|
||||
"description": "Optional. Defines the order in which to apply server customizations.\n"
|
||||
},
|
||||
"mount_prefix": {
|
||||
"type": "string",
|
||||
"description": "Optional. URL prefix to prepend to all the routes.\n"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
@@ -99,6 +99,17 @@
|
||||
},
|
||||
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
|
||||
},
|
||||
"encryption": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/EncryptionConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
|
||||
},
|
||||
"env": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -292,6 +303,17 @@
|
||||
},
|
||||
"description": "Optional. Additional Docker instructions that will be appended to your base Dockerfile.\n\nUseful for installing OS packages, setting environment variables, etc."
|
||||
},
|
||||
"encryption": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/$defs/EncryptionConfig"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Optional. Custom at-rest encryption config, including the path to your Python encryption logic.\n\nAllows you to implement custom encryption for sensitive data stored in the database.\n"
|
||||
},
|
||||
"env": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -405,6 +427,10 @@
|
||||
"description": "Configuration for custom authentication logic and how it integrates into the OpenAPI spec.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache": {
|
||||
"$ref": "#/$defs/CacheConfig",
|
||||
"description": "Optional. Cache configuration for the server.\n"
|
||||
},
|
||||
"disable_studio_auth": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. Whether to disable LangSmith API-key authentication for requests originating the Studio.\n\nDefaults to False, meaning that if a particular header is set, the server will verify the `x-api-key` header\nvalue is a valid API key for the deployment's workspace. If `True`, all requests will go through your custom\nauthentication logic, regardless of origin of the request.\n"
|
||||
@@ -420,6 +446,29 @@
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"CacheConfig": {
|
||||
"title": "CacheConfig",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cache_keys": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": "Optional. List of header keys to use for caching.\n"
|
||||
},
|
||||
"max_size": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Maximum size of the cache.\n"
|
||||
},
|
||||
"ttl_seconds": {
|
||||
"type": "integer",
|
||||
"description": "Optional. Time-to-live in seconds for cached items.\n"
|
||||
}
|
||||
},
|
||||
"required": [],
|
||||
"description": "dict() -> new empty dictionary\ndict(mapping) -> new dictionary initialized from a mapping object's\n (key, value) pairs\ndict(iterable) -> new dictionary initialized as if via:\n d = {}\n for k, v in iterable:\n d[k] = v\ndict(**kwargs) -> new dictionary initialized with the name=value pairs\n in the keyword argument list. For example: dict(one=1, two=2)"
|
||||
},
|
||||
"SecurityConfig": {
|
||||
"title": "SecurityConfig",
|
||||
"description": "Configuration for OpenAPI security definitions and requirements.\n\nUseful for specifying global or path-level authentication and authorization flows\n(e.g., OAuth2, API key headers, etc.).",
|
||||
@@ -565,6 +614,17 @@
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"EncryptionConfig": {
|
||||
"title": "EncryptionConfig",
|
||||
"description": "Configuration for custom at-rest encryption logic.\n\n Allows you to implement custom encryption for sensitive data stored in the database,\n including metadata fields and checkpoint blobs.",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
},
|
||||
"HttpConfig": {
|
||||
"title": "HttpConfig",
|
||||
"description": "Configuration for the built-in HTTP server that powers your deployment's routes and endpoints.",
|
||||
@@ -596,13 +656,17 @@
|
||||
],
|
||||
"description": "Optional. Defines CORS restrictions. If omitted, no special rules are set and\ncross-origin behavior depends on default server settings.\n"
|
||||
},
|
||||
"disable_a2a": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /a2a routes are removed, disabling default support to expose the deployment as an agent-to-agent (A2A) server.\n\nDefault is False.\n"
|
||||
},
|
||||
"disable_assistants": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /assistants routes are removed from the server.\n\nDefault is False (meaning /assistants is enabled).\n"
|
||||
},
|
||||
"disable_mcp": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /mcp routes are removed, disabling the MCP server.\n\nDefault is False.\n"
|
||||
"description": "Optional. If `True`, /mcp routes are removed, disabling default support to expose the deployment as an MCP server.\n\nDefault is False.\n"
|
||||
},
|
||||
"disable_meta": {
|
||||
"type": "boolean",
|
||||
@@ -620,6 +684,14 @@
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /threads routes are removed.\n\nDefault is False.\n"
|
||||
},
|
||||
"disable_ui": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, /ui routes are removed, disabling the UI server.\n\nDefault is False.\n"
|
||||
},
|
||||
"disable_webhooks": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, webhooks are disabled. Runs created with an associated webhook will\nstill be executed, but the webhook event will not be sent.\n\nDefault is False.\n"
|
||||
},
|
||||
"enable_custom_route_auth": {
|
||||
"type": "boolean",
|
||||
"description": "Optional. If `True`, authentication is enabled for custom routes,\nnot just the routes that are protected by default.\n(Routes protected by default include /assistants, /threads, and /runs).\n\nDefault is False. This flag only affects authentication behavior\nif `app` is provided and contains custom routes.\n"
|
||||
@@ -648,6 +720,10 @@
|
||||
}
|
||||
],
|
||||
"description": "Optional. Defines the order in which to apply server customizations.\n"
|
||||
},
|
||||
"mount_prefix": {
|
||||
"type": "string",
|
||||
"description": "Optional. URL prefix to prepend to all the routes.\n"
|
||||
}
|
||||
},
|
||||
"required": []
|
||||
|
||||
@@ -48,6 +48,7 @@ def test_validate_config():
|
||||
"env": {},
|
||||
"store": None,
|
||||
"auth": None,
|
||||
"encryption": None,
|
||||
"checkpointer": None,
|
||||
"http": None,
|
||||
"ui": None,
|
||||
@@ -74,6 +75,7 @@ def test_validate_config():
|
||||
"env": env,
|
||||
"store": None,
|
||||
"auth": None,
|
||||
"encryption": None,
|
||||
"checkpointer": None,
|
||||
"http": None,
|
||||
"ui": None,
|
||||
@@ -748,6 +750,57 @@ RUN (test ! -f /api/langgraph_api/js/build.mts && echo "Prebuild script not foun
|
||||
assert additional_contexts == {}
|
||||
|
||||
|
||||
def test_config_to_docker_python_encryption():
|
||||
# Test that encryption config is included in validation
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
validated = validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": graphs,
|
||||
"dependencies": ["."],
|
||||
"encryption": {"path": "./encryption.py:encryption"},
|
||||
}
|
||||
)
|
||||
|
||||
# Verify that encryption config is preserved after validation
|
||||
assert validated.get("encryption") is not None
|
||||
assert validated["encryption"]["path"] == "./encryption.py:encryption"
|
||||
|
||||
|
||||
def test_config_to_docker_python_encryption_bad_path():
|
||||
# Test that invalid encryption path format raises ValueError
|
||||
graphs = {"agent": "./agent.py:graph"}
|
||||
with pytest.raises(ValueError, match="Invalid encryption.path format"):
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"graphs": graphs,
|
||||
"dependencies": ["."],
|
||||
"encryption": {"path": "./encryption.py"}, # Missing :attribute
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_config_to_docker_python_encryption_formatted():
|
||||
# Test that encryption config is properly formatted in Docker output
|
||||
graphs = {"agent": "./graphs/agent.py:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
PATH_TO_CONFIG,
|
||||
validate_config(
|
||||
{
|
||||
"python_version": "3.11",
|
||||
"dependencies": ["."],
|
||||
"graphs": graphs,
|
||||
"encryption": {"path": "./agent.py:my_encryption"},
|
||||
}
|
||||
),
|
||||
"langchain/langgraph-api",
|
||||
)
|
||||
# Verify that LANGGRAPH_ENCRYPTION is in the docker output with the correct path
|
||||
assert "LANGGRAPH_ENCRYPTION=" in actual_docker_stdin
|
||||
assert "/deps/outer-unit_tests/unit_tests/agent.py:my_encryption" in actual_docker_stdin
|
||||
|
||||
|
||||
def test_config_to_docker_nodejs_internal_docker_tag():
|
||||
graphs = {"agent": "./graphs/agent.js:graph"}
|
||||
actual_docker_stdin, additional_contexts = config_to_docker(
|
||||
|
||||
@@ -67,7 +67,7 @@ While LangGraph can be used standalone, it also integrates seamlessly with any L
|
||||
- [LangChain](https://docs.langchain.com/oss/python/langchain/overview) – Provides integrations and composable components to streamline LLM application development.
|
||||
|
||||
> [!NOTE]
|
||||
> Looking for the JS version of LangGraph? See the [JS repo](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://langchain-ai.github.io/langgraphjs/).
|
||||
> Looking for the JS version of LangGraph? See the [JS repo](https://github.com/langchain-ai/langgraphjs) and the [JS docs](https://docs.langchain.com/oss/javascript/langgraph/overview).
|
||||
|
||||
## Additional resources
|
||||
|
||||
|
||||
@@ -725,9 +725,10 @@ class RemoteGraph(PregelProtocol):
|
||||
input = None
|
||||
else:
|
||||
command = None
|
||||
thread_id = sanitized_config.get("configurable", {}).pop("thread_id", None)
|
||||
|
||||
for chunk in sync_client.runs.stream(
|
||||
thread_id=sanitized_config["configurable"].get("thread_id"),
|
||||
thread_id=thread_id,
|
||||
assistant_id=self.assistant_id,
|
||||
input=input,
|
||||
command=command,
|
||||
@@ -834,9 +835,10 @@ class RemoteGraph(PregelProtocol):
|
||||
input = None
|
||||
else:
|
||||
command = None
|
||||
thread_id = sanitized_config.get("configurable", {}).pop("thread_id", None)
|
||||
|
||||
async for chunk in client.runs.stream(
|
||||
thread_id=sanitized_config["configurable"].get("thread_id"),
|
||||
thread_id=thread_id,
|
||||
assistant_id=self.assistant_id,
|
||||
input=input,
|
||||
command=command,
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "langgraph"
|
||||
version = "1.0.3"
|
||||
version = "1.0.4"
|
||||
description = "Building stateful, multi-actor applications with LLMs"
|
||||
authors = []
|
||||
requires-python = ">=3.10"
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -3271,7 +3271,7 @@ def test_root_graph(
|
||||
content="result for query",
|
||||
name="search_api",
|
||||
tool_call_id="tool_call123",
|
||||
id="00000000-0000-4000-8000-000000000024",
|
||||
id="00000000-0000-4000-8000-000000000004",
|
||||
)
|
||||
]
|
||||
},
|
||||
@@ -3294,7 +3294,7 @@ def test_root_graph(
|
||||
content="result for another",
|
||||
name="search_api",
|
||||
tool_call_id="tool_call456",
|
||||
id="00000000-0000-4000-8000-000000000030",
|
||||
id="00000000-0000-4000-8000-000000000005",
|
||||
)
|
||||
]
|
||||
},
|
||||
@@ -3898,7 +3898,7 @@ def test_root_graph(
|
||||
"__root__": [
|
||||
HumanMessage(
|
||||
content="what is weather in sf",
|
||||
id="00000000-0000-4000-8000-000000000051",
|
||||
id="00000000-0000-4000-8000-000000000008",
|
||||
),
|
||||
AIMessage(
|
||||
content="",
|
||||
@@ -3918,7 +3918,7 @@ def test_root_graph(
|
||||
),
|
||||
AIMessage(content="answer", id="ai2"),
|
||||
AIMessage(
|
||||
content="an extra message", id="00000000-0000-4000-8000-000000000066"
|
||||
content="an extra message", id="00000000-0000-4000-8000-000000000010"
|
||||
),
|
||||
HumanMessage(content="what is weather in la"),
|
||||
],
|
||||
|
||||
@@ -840,6 +840,46 @@ def test_invoke():
|
||||
assert result == {"messages": [{"type": "human", "content": "world"}]}
|
||||
|
||||
|
||||
def test_invoke_sanitizes_thread_id():
|
||||
# Ensure that invoking with thread_id passes thread_id as a top-level arg
|
||||
# and removes it from the config body.
|
||||
mock_sync_client = MagicMock()
|
||||
mock_sync_client.runs.stream.return_value = []
|
||||
remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client)
|
||||
|
||||
config = {"configurable": {"thread_id": "thread_1"}}
|
||||
remote_pregel.invoke(
|
||||
{"input": {"messages": [{"type": "human", "content": "hello"}]}}, config
|
||||
)
|
||||
|
||||
assert mock_sync_client.runs.stream.called
|
||||
_, kwargs = mock_sync_client.runs.stream.call_args
|
||||
assert kwargs.get("thread_id") == "thread_1"
|
||||
passed_config = kwargs.get("config") or {}
|
||||
assert "configurable" in passed_config
|
||||
assert "thread_id" not in passed_config["configurable"]
|
||||
assert not passed_config["configurable"]
|
||||
|
||||
|
||||
def test_stream_sanitizes_thread_id():
|
||||
# Ensure that streaming with thread_id passes thread_id as a top-level arg
|
||||
# and removes it from the config body.
|
||||
mock_sync_client = MagicMock()
|
||||
mock_sync_client.runs.stream.return_value = []
|
||||
remote_pregel = RemoteGraph("test_graph_id", sync_client=mock_sync_client)
|
||||
|
||||
config = {"configurable": {"thread_id": "thread_2"}}
|
||||
list(remote_pregel.stream({"input": {"messages": []}}, config))
|
||||
|
||||
assert mock_sync_client.runs.stream.called
|
||||
_, kwargs = mock_sync_client.runs.stream.call_args
|
||||
assert kwargs.get("thread_id") == "thread_2"
|
||||
passed_config = kwargs.get("config") or {}
|
||||
assert "configurable" in passed_config
|
||||
assert "thread_id" not in passed_config["configurable"]
|
||||
assert not passed_config["configurable"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_ainvoke():
|
||||
# set up test
|
||||
@@ -879,8 +919,8 @@ async def test_langgraph_cloud_integration():
|
||||
from langgraph.graph import END, START, MessagesState, StateGraph
|
||||
|
||||
# create RemotePregel instance
|
||||
client = get_client()
|
||||
sync_client = get_sync_client()
|
||||
client = get_client(url="http://localhost:8123")
|
||||
sync_client = get_sync_client(url="http://localhost:8123")
|
||||
remote_pregel = RemoteGraph(
|
||||
"agent",
|
||||
client=client,
|
||||
|
||||
Generated
+895
-780
File diff suppressed because it is too large
Load Diff
Generated
+684
-487
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ lint lint_diff:
|
||||
uv run ruff check .
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
|
||||
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --select I $(PYTHON_FILES)
|
||||
uvx ty check .
|
||||
uv run ty check .
|
||||
|
||||
format format_diff:
|
||||
uv run ruff check --select I --fix $(PYTHON_FILES)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from langgraph_sdk.auth import Auth
|
||||
from langgraph_sdk.client import get_client, get_sync_client
|
||||
from langgraph_sdk.encryption import Encryption
|
||||
from langgraph_sdk.encryption.types import EncryptionContext
|
||||
|
||||
__version__ = "0.2.10"
|
||||
__version__ = "0.2.14"
|
||||
|
||||
__all__ = ["Auth", "get_client", "get_sync_client"]
|
||||
__all__ = ["Auth", "Encryption", "EncryptionContext", "get_client", "get_sync_client"]
|
||||
|
||||
@@ -35,6 +35,7 @@ from langgraph_sdk.schema import (
|
||||
Assistant,
|
||||
AssistantSelectField,
|
||||
AssistantSortBy,
|
||||
AssistantsSearchResponse,
|
||||
AssistantVersion,
|
||||
CancelAction,
|
||||
Checkpoint,
|
||||
@@ -48,6 +49,7 @@ from langgraph_sdk.schema import (
|
||||
Durability,
|
||||
GraphSchema,
|
||||
IfNotExists,
|
||||
Input,
|
||||
Item,
|
||||
Json,
|
||||
ListNamespaceResponse,
|
||||
@@ -80,25 +82,37 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
RESERVED_HEADERS = ("x-api-key",)
|
||||
|
||||
NOT_PROVIDED = cast(None, object())
|
||||
|
||||
def _get_api_key(api_key: str | None = None) -> str | None:
|
||||
|
||||
def _get_api_key(api_key: str | None = NOT_PROVIDED) -> str | None:
|
||||
"""Get the API key from the environment.
|
||||
Precedence:
|
||||
1. explicit argument
|
||||
2. LANGGRAPH_API_KEY
|
||||
3. LANGSMITH_API_KEY
|
||||
4. LANGCHAIN_API_KEY
|
||||
1. explicit string argument
|
||||
2. LANGGRAPH_API_KEY (if api_key not provided)
|
||||
3. LANGSMITH_API_KEY (if api_key not provided)
|
||||
4. LANGCHAIN_API_KEY (if api_key not provided)
|
||||
|
||||
Args:
|
||||
api_key: The API key to use. Can be:
|
||||
- A string: use this exact API key
|
||||
- None: explicitly skip loading from environment
|
||||
- NOT_PROVIDED (default): auto-load from environment variables
|
||||
"""
|
||||
if api_key:
|
||||
if isinstance(api_key, str):
|
||||
return api_key
|
||||
for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]:
|
||||
if env := os.getenv(f"{prefix}_API_KEY"):
|
||||
return env.strip().strip('"').strip("'")
|
||||
return None # type: ignore
|
||||
if api_key is NOT_PROVIDED:
|
||||
# api_key is not explicitly provided, try to load from environment
|
||||
for prefix in ["LANGGRAPH", "LANGSMITH", "LANGCHAIN"]:
|
||||
if env := os.getenv(f"{prefix}_API_KEY"):
|
||||
return env.strip().strip('"').strip("'")
|
||||
# api_key is explicitly None, don't load from environment
|
||||
return None
|
||||
|
||||
|
||||
def _get_headers(
|
||||
api_key: str | None, custom_headers: Mapping[str, str] | None
|
||||
api_key: str | None,
|
||||
custom_headers: Mapping[str, str] | None,
|
||||
) -> dict[str, str]:
|
||||
"""Combine api_key and custom user-provided headers."""
|
||||
custom_headers = custom_headers or {}
|
||||
@@ -110,9 +124,9 @@ def _get_headers(
|
||||
"User-Agent": f"langgraph-sdk-py/{langgraph_sdk.__version__}",
|
||||
**custom_headers,
|
||||
}
|
||||
api_key = _get_api_key(api_key)
|
||||
if api_key:
|
||||
headers["x-api-key"] = api_key
|
||||
resolved_api_key = _get_api_key(api_key)
|
||||
if resolved_api_key:
|
||||
headers["x-api-key"] = resolved_api_key
|
||||
|
||||
return headers
|
||||
|
||||
@@ -163,7 +177,7 @@ def _get_run_metadata_from_response(
|
||||
def get_client(
|
||||
*,
|
||||
url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_key: str | None = NOT_PROVIDED,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
timeout: TimeoutTypes | None = None,
|
||||
) -> LangGraphClient:
|
||||
@@ -176,14 +190,16 @@ def get_client(
|
||||
url:
|
||||
Base URL of the LangGraph API.
|
||||
- If `None`, the client first attempts an in-process connection via ASGI transport.
|
||||
If that fails, it falls back to `http://localhost:8123`.
|
||||
If that fails, it defers registration until after app initialization. This
|
||||
only works if the client is used from within the Agent server.
|
||||
api_key:
|
||||
API key for authentication. If omitted, the client reads from environment
|
||||
variables in the following order:
|
||||
1. Function argument
|
||||
2. `LANGGRAPH_API_KEY`
|
||||
3. `LANGSMITH_API_KEY`
|
||||
4. `LANGCHAIN_API_KEY`
|
||||
API key for authentication. Can be:
|
||||
- A string: use this exact API key
|
||||
- `None`: explicitly skip loading from environment variables
|
||||
- Not provided (default): auto-load from environment in this order:
|
||||
1. `LANGGRAPH_API_KEY`
|
||||
2. `LANGSMITH_API_KEY`
|
||||
3. `LANGCHAIN_API_KEY`
|
||||
headers:
|
||||
Additional HTTP headers to include in requests. Merged with authentication headers.
|
||||
timeout:
|
||||
@@ -224,23 +240,38 @@ def get_client(
|
||||
input={"messages": [{"role": "user", "content": "Foo"}]},
|
||||
)
|
||||
```
|
||||
|
||||
???+ example "Skip auto-loading API key from environment:"
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
|
||||
# Don't load API key from environment variables
|
||||
client = get_client(
|
||||
url="http://localhost:8123",
|
||||
api_key=None
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
transport: httpx.AsyncBaseTransport | None = None
|
||||
if url is None:
|
||||
url = "http://api"
|
||||
if os.environ.get("__LANGGRAPH_DEFER_LOOPBACK_TRANSPORT") == "true":
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth")
|
||||
_registered_transports.append(transport)
|
||||
url = "http://api"
|
||||
else:
|
||||
try:
|
||||
from langgraph_api.server import app # type: ignore
|
||||
|
||||
url = "http://api"
|
||||
|
||||
transport = get_asgi_transport()(app, root_path="/noauth")
|
||||
except Exception:
|
||||
url = "http://localhost:8123"
|
||||
logger.debug(
|
||||
"Failed to connect to in-process LangGraph server. Deferring configuration.",
|
||||
exc_info=True,
|
||||
)
|
||||
transport = get_asgi_transport()(app=None, root_path="/noauth")
|
||||
_registered_transports.append(transport)
|
||||
|
||||
if transport is None:
|
||||
transport = httpx.AsyncHTTPTransport(retries=5)
|
||||
@@ -1028,6 +1059,7 @@ class AssistantsClient:
|
||||
f"/assistants/{assistant_id}", headers=headers, params=params
|
||||
)
|
||||
|
||||
@overload
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
@@ -1039,9 +1071,43 @@ class AssistantsClient:
|
||||
sort_by: AssistantSortBy | None = None,
|
||||
sort_order: SortOrder | None = None,
|
||||
select: list[AssistantSelectField] | None = None,
|
||||
response_format: Literal["object"],
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[Assistant]:
|
||||
) -> AssistantsSearchResponse: ...
|
||||
|
||||
@overload
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
metadata: Json = None,
|
||||
graph_id: str | None = None,
|
||||
name: str | None = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
sort_by: AssistantSortBy | None = None,
|
||||
sort_order: SortOrder | None = None,
|
||||
select: list[AssistantSelectField] | None = None,
|
||||
response_format: Literal["array"] = "array",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[Assistant]: ...
|
||||
|
||||
async def search(
|
||||
self,
|
||||
*,
|
||||
metadata: Json = None,
|
||||
graph_id: str | None = None,
|
||||
name: str | None = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
sort_by: AssistantSortBy | None = None,
|
||||
sort_order: SortOrder | None = None,
|
||||
select: list[AssistantSelectField] | None = None,
|
||||
response_format: Literal["array", "object"] = "array",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> AssistantsSearchResponse | list[Assistant]:
|
||||
"""Search for assistants.
|
||||
|
||||
Args:
|
||||
@@ -1054,24 +1120,38 @@ class AssistantsClient:
|
||||
offset: The number of results to skip.
|
||||
sort_by: The field to sort by.
|
||||
sort_order: The order to sort by.
|
||||
select: Specific assistant fields to include in the response.
|
||||
response_format: Controls the response shape. Use ``"array"`` (default)
|
||||
to return a bare list of assistants, or ``"object"`` to return
|
||||
a mapping containing assistants plus pagination metadata.
|
||||
Defaults to "array", though this default will be changed to "object" in a future release.
|
||||
headers: Optional custom headers to include with the request.
|
||||
params: Optional query parameters to include with the request.
|
||||
|
||||
Returns:
|
||||
A list of assistants.
|
||||
A list of assistants (when ``response_format=\"array\"``) or a mapping
|
||||
with the assistants and the next pagination cursor (when
|
||||
``response_format=\"object\"``).
|
||||
|
||||
???+ example "Example Usage"
|
||||
|
||||
```python
|
||||
client = get_client(url="http://localhost:2024")
|
||||
assistants = await client.assistants.search(
|
||||
response = await client.assistants.search(
|
||||
metadata = {"name":"my_name"},
|
||||
graph_id="my_graph_id",
|
||||
limit=5,
|
||||
offset=5
|
||||
offset=5,
|
||||
response_format="object"
|
||||
)
|
||||
next_cursor = response["next"]
|
||||
assistants = response["assistants"]
|
||||
```
|
||||
"""
|
||||
if response_format not in ("array", "object"):
|
||||
raise ValueError(
|
||||
f"response_format must be 'array' or 'object', got {response_format!r}"
|
||||
)
|
||||
payload: dict[str, Any] = {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@@ -1088,12 +1168,25 @@ class AssistantsClient:
|
||||
payload["sort_order"] = sort_order
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return await self.http.post(
|
||||
"/assistants/search",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
next_cursor: str | None = None
|
||||
|
||||
def capture_pagination(response: httpx.Response) -> None:
|
||||
nonlocal next_cursor
|
||||
next_cursor = response.headers.get("X-Pagination-Next")
|
||||
|
||||
assistants = cast(
|
||||
list[Assistant],
|
||||
await self.http.post(
|
||||
"/assistants/search",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
on_response=capture_pagination if response_format == "object" else None,
|
||||
),
|
||||
)
|
||||
if response_format == "object":
|
||||
return {"assistants": assistants, "next": next_cursor}
|
||||
return assistants
|
||||
|
||||
async def count(
|
||||
self,
|
||||
@@ -1881,7 +1974,7 @@ class RunsClient:
|
||||
thread_id: str,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -1911,7 +2004,7 @@ class RunsClient:
|
||||
thread_id: None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -1937,7 +2030,7 @@ class RunsClient:
|
||||
thread_id: str | None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -2096,7 +2189,7 @@ class RunsClient:
|
||||
thread_id: None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -2122,7 +2215,7 @@ class RunsClient:
|
||||
thread_id: str,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -2149,7 +2242,7 @@ class RunsClient:
|
||||
thread_id: str | None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -2351,7 +2444,7 @@ class RunsClient:
|
||||
thread_id: str,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
@@ -2378,7 +2471,7 @@ class RunsClient:
|
||||
thread_id: None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
@@ -2402,7 +2495,7 @@ class RunsClient:
|
||||
thread_id: str | None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
@@ -2875,7 +2968,7 @@ class CronClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
schedule: str,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
@@ -2958,7 +3051,7 @@ class CronClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
schedule: str,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
@@ -3470,7 +3563,7 @@ class StoreClient:
|
||||
def get_sync_client(
|
||||
*,
|
||||
url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_key: str | None = NOT_PROVIDED,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
timeout: TimeoutTypes | None = None,
|
||||
) -> SyncLangGraphClient:
|
||||
@@ -3478,12 +3571,13 @@ def get_sync_client(
|
||||
|
||||
Args:
|
||||
url: The URL of the LangGraph API.
|
||||
api_key: The API key. If not provided, it will be read from the environment.
|
||||
Precedence:
|
||||
1. explicit argument
|
||||
2. LANGGRAPH_API_KEY
|
||||
3. LANGSMITH_API_KEY
|
||||
4. LANGCHAIN_API_KEY
|
||||
api_key: API key for authentication. Can be:
|
||||
- A string: use this exact API key
|
||||
- `None`: explicitly skip loading from environment variables
|
||||
- Not provided (default): auto-load from environment in this order:
|
||||
1. `LANGGRAPH_API_KEY`
|
||||
2. `LANGSMITH_API_KEY`
|
||||
3. `LANGCHAIN_API_KEY`
|
||||
headers: Optional custom headers
|
||||
timeout: Optional timeout configuration for the HTTP client.
|
||||
Accepts an httpx.Timeout instance, a float (seconds), or a tuple of timeouts.
|
||||
@@ -3504,6 +3598,18 @@ def get_sync_client(
|
||||
# example usage: client.<model>.<method_name>()
|
||||
assistant = client.assistants.get(assistant_id="some_uuid")
|
||||
```
|
||||
|
||||
???+ example "Skip auto-loading API key from environment:"
|
||||
|
||||
```python
|
||||
from langgraph_sdk import get_sync_client
|
||||
|
||||
# Don't load API key from environment variables
|
||||
client = get_sync_client(
|
||||
url="http://localhost:8123",
|
||||
api_key=None
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
if url is None:
|
||||
@@ -4289,6 +4395,7 @@ class SyncAssistantsClient:
|
||||
"""
|
||||
self.http.delete(f"/assistants/{assistant_id}", headers=headers, params=params)
|
||||
|
||||
@overload
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
@@ -4300,9 +4407,43 @@ class SyncAssistantsClient:
|
||||
sort_by: AssistantSortBy | None = None,
|
||||
sort_order: SortOrder | None = None,
|
||||
select: list[AssistantSelectField] | None = None,
|
||||
response_format: Literal["object"],
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[Assistant]:
|
||||
) -> AssistantsSearchResponse: ...
|
||||
|
||||
@overload
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
metadata: Json = None,
|
||||
graph_id: str | None = None,
|
||||
name: str | None = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
sort_by: AssistantSortBy | None = None,
|
||||
sort_order: SortOrder | None = None,
|
||||
select: list[AssistantSelectField] | None = None,
|
||||
response_format: Literal["array"] = "array",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> list[Assistant]: ...
|
||||
|
||||
def search(
|
||||
self,
|
||||
*,
|
||||
metadata: Json = None,
|
||||
graph_id: str | None = None,
|
||||
name: str | None = None,
|
||||
limit: int = 10,
|
||||
offset: int = 0,
|
||||
sort_by: AssistantSortBy | None = None,
|
||||
sort_order: SortOrder | None = None,
|
||||
select: list[AssistantSelectField] | None = None,
|
||||
response_format: Literal["array", "object"] = "array",
|
||||
headers: Mapping[str, str] | None = None,
|
||||
params: QueryParamTypes | None = None,
|
||||
) -> AssistantsSearchResponse | list[Assistant]:
|
||||
"""Search for assistants.
|
||||
|
||||
Args:
|
||||
@@ -4313,23 +4454,37 @@ class SyncAssistantsClient:
|
||||
The filtering logic will match assistants where 'name' is a substring (case insensitive) of the assistant name.
|
||||
limit: The maximum number of results to return.
|
||||
offset: The number of results to skip.
|
||||
sort_by: The field to sort by.
|
||||
sort_order: The order to sort by.
|
||||
select: Specific assistant fields to include in the response.
|
||||
response_format: Controls the response shape. Use ``"array"`` (default)
|
||||
to return a bare list of assistants, or ``"object"`` to return
|
||||
a mapping containing assistants plus pagination metadata.
|
||||
Defaults to "array", though this default will be changed to "object" in a future release.
|
||||
headers: Optional custom headers to include with the request.
|
||||
|
||||
Returns:
|
||||
A list of assistants.
|
||||
A list of assistants (when ``response_format=\"array\"``) or a mapping
|
||||
with the assistants and the next pagination cursor (when
|
||||
``response_format=\"object\"``).
|
||||
|
||||
???+ example "Example Usage"
|
||||
|
||||
```python
|
||||
client = get_sync_client(url="http://localhost:2024")
|
||||
assistants = client.assistants.search(
|
||||
response = client.assistants.search(
|
||||
metadata = {"name":"my_name"},
|
||||
graph_id="my_graph_id",
|
||||
limit=5,
|
||||
offset=5
|
||||
offset=5,
|
||||
response_format="object",
|
||||
)
|
||||
assistants = response["assistants"]
|
||||
next_cursor = response["next"]
|
||||
```
|
||||
"""
|
||||
if response_format not in ("array", "object"):
|
||||
raise ValueError("response_format must be 'array' or 'object'")
|
||||
payload: dict[str, Any] = {
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
@@ -4346,12 +4501,25 @@ class SyncAssistantsClient:
|
||||
payload["sort_order"] = sort_order
|
||||
if select:
|
||||
payload["select"] = select
|
||||
return self.http.post(
|
||||
"/assistants/search",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
next_cursor: str | None = None
|
||||
|
||||
def capture_pagination(response: httpx.Response) -> None:
|
||||
nonlocal next_cursor
|
||||
next_cursor = response.headers.get("X-Pagination-Next")
|
||||
|
||||
assistants = cast(
|
||||
list[Assistant],
|
||||
self.http.post(
|
||||
"/assistants/search",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
params=params,
|
||||
on_response=capture_pagination if response_format == "object" else None,
|
||||
),
|
||||
)
|
||||
if response_format == "object":
|
||||
return {"assistants": assistants, "next": next_cursor}
|
||||
return assistants
|
||||
|
||||
def count(
|
||||
self,
|
||||
@@ -5121,7 +5289,7 @@ class SyncRunsClient:
|
||||
thread_id: str,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -5150,7 +5318,7 @@ class SyncRunsClient:
|
||||
thread_id: None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -5177,7 +5345,7 @@ class SyncRunsClient:
|
||||
thread_id: str | None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -5332,7 +5500,7 @@ class SyncRunsClient:
|
||||
thread_id: None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -5358,7 +5526,7 @@ class SyncRunsClient:
|
||||
thread_id: str,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -5385,7 +5553,7 @@ class SyncRunsClient:
|
||||
thread_id: str | None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
stream_mode: StreamMode | Sequence[StreamMode] = "values",
|
||||
stream_subgraphs: bool = False,
|
||||
@@ -5587,7 +5755,7 @@ class SyncRunsClient:
|
||||
thread_id: str,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
@@ -5614,7 +5782,7 @@ class SyncRunsClient:
|
||||
thread_id: None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
@@ -5638,7 +5806,7 @@ class SyncRunsClient:
|
||||
thread_id: str | None,
|
||||
assistant_id: str,
|
||||
*,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
command: Command | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
@@ -6091,7 +6259,7 @@ class SyncCronClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
schedule: str,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
@@ -6170,7 +6338,7 @@ class SyncCronClient:
|
||||
assistant_id: str,
|
||||
*,
|
||||
schedule: str,
|
||||
input: Mapping[str, Any] | None = None,
|
||||
input: Input | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
config: Config | None = None,
|
||||
context: Context | None = None,
|
||||
|
||||
@@ -0,0 +1,554 @@
|
||||
"""Custom encryption support for LangGraph.
|
||||
|
||||
.. warning::
|
||||
This API is in beta and may change in future versions.
|
||||
|
||||
This module provides a framework for implementing custom at-rest encryption
|
||||
in LangGraph applications. Similar to the Auth system, it allows developers
|
||||
to define custom encryption and decryption handlers that are executed
|
||||
server-side.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import inspect
|
||||
import typing
|
||||
import warnings
|
||||
|
||||
from langgraph_sdk.encryption import types
|
||||
|
||||
|
||||
class LangGraphBetaWarning(UserWarning):
|
||||
"""Warning for beta features in LangGraph SDK."""
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _warn_encryption_beta() -> None:
|
||||
warnings.warn(
|
||||
"The Encryption API is in beta and may change in future versions.",
|
||||
LangGraphBetaWarning,
|
||||
stacklevel=4,
|
||||
)
|
||||
|
||||
|
||||
class DuplicateHandlerError(Exception):
|
||||
"""Raised when attempting to register a duplicate encryption/decryption handler."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
def _validate_handler(fn: typing.Callable, handler_type: str) -> None:
|
||||
"""Validate that a handler function has the correct signature.
|
||||
|
||||
Args:
|
||||
fn: The handler function to validate
|
||||
handler_type: Description of the handler for error messages
|
||||
|
||||
Raises:
|
||||
TypeError: If the handler is not an async function or has wrong parameter count
|
||||
"""
|
||||
if not inspect.iscoroutinefunction(fn):
|
||||
raise TypeError(f"{handler_type} must be an async function, got {type(fn)}")
|
||||
|
||||
sig = inspect.signature(fn)
|
||||
params = [
|
||||
p
|
||||
for p in sig.parameters.values()
|
||||
if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)
|
||||
]
|
||||
if len(params) != 2:
|
||||
raise TypeError(
|
||||
f"{handler_type} must accept exactly 2 parameters "
|
||||
f"(ctx, data), got {len(params)}"
|
||||
)
|
||||
|
||||
|
||||
class _JsonEncryptDecorators:
|
||||
"""Dynamic decorator factory for JSON encryption handlers.
|
||||
|
||||
Supports both default and model-specific handlers:
|
||||
- @encrypt.json - default handler for all models
|
||||
- @encrypt.json.thread - handler for thread model
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Encryption):
|
||||
self._parent = parent
|
||||
|
||||
def __call__(self, fn: types.JsonEncryptor) -> types.JsonEncryptor:
|
||||
"""Register the default JSON encryption handler.
|
||||
|
||||
Args:
|
||||
fn: The handler function
|
||||
|
||||
Returns:
|
||||
The registered handler function
|
||||
|
||||
Raises:
|
||||
DuplicateHandlerError: If handler already registered
|
||||
TypeError: If handler has invalid signature
|
||||
"""
|
||||
if self._parent._json_encryptor is not None:
|
||||
raise DuplicateHandlerError("Default JSON encryptor already registered")
|
||||
_validate_handler(fn, "Default JSON encryptor")
|
||||
self._parent._json_encryptor = fn
|
||||
return fn
|
||||
|
||||
def __getattr__(
|
||||
self, model: str
|
||||
) -> typing.Callable[[types.JsonEncryptor], types.JsonEncryptor]:
|
||||
"""Dynamic attribute access for model-specific handlers.
|
||||
|
||||
Allows @encryption.encrypt.json.thread, @encryption.encrypt.json.assistant, etc.
|
||||
|
||||
Raises:
|
||||
DuplicateHandlerError: If handler already registered for this model
|
||||
TypeError: If handler has invalid signature
|
||||
"""
|
||||
|
||||
def decorator(fn: types.JsonEncryptor) -> types.JsonEncryptor:
|
||||
if model in self._parent._json_encryptors:
|
||||
raise DuplicateHandlerError(
|
||||
f"JSON encryptor for model '{model}' already registered"
|
||||
)
|
||||
_validate_handler(fn, f"JSON encryptor for model '{model}'")
|
||||
self._parent._json_encryptors[model] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class _JsonDecryptDecorators:
|
||||
"""Dynamic decorator factory for JSON decryption handlers.
|
||||
|
||||
Supports both default and model-specific handlers:
|
||||
- @encryption.decrypt.json - default handler for all models
|
||||
- @encryption.decrypt.json.thread - handler for thread model
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Encryption):
|
||||
self._parent = parent
|
||||
|
||||
def __call__(self, fn: types.JsonDecryptor) -> types.JsonDecryptor:
|
||||
"""Register the default JSON decryption handler.
|
||||
|
||||
Args:
|
||||
fn: The handler function
|
||||
|
||||
Returns:
|
||||
The registered handler function
|
||||
|
||||
Raises:
|
||||
DuplicateHandlerError: If handler already registered
|
||||
TypeError: If handler has invalid signature
|
||||
"""
|
||||
if self._parent._json_decryptor is not None:
|
||||
raise DuplicateHandlerError("Default JSON decryptor already registered")
|
||||
_validate_handler(fn, "Default JSON decryptor")
|
||||
self._parent._json_decryptor = fn
|
||||
return fn
|
||||
|
||||
def __getattr__(
|
||||
self, model: str
|
||||
) -> typing.Callable[[types.JsonDecryptor], types.JsonDecryptor]:
|
||||
"""Dynamic attribute access for model-specific handlers.
|
||||
|
||||
Allows @encryption.decrypt.json.thread, @encryption.decrypt.json.assistant, etc.
|
||||
|
||||
Raises:
|
||||
DuplicateHandlerError: If handler already registered for this model
|
||||
TypeError: If handler has invalid signature
|
||||
"""
|
||||
|
||||
def decorator(fn: types.JsonDecryptor) -> types.JsonDecryptor:
|
||||
if model in self._parent._json_decryptors:
|
||||
raise DuplicateHandlerError(
|
||||
f"JSON decryptor for model '{model}' already registered"
|
||||
)
|
||||
_validate_handler(fn, f"JSON decryptor for model '{model}'")
|
||||
self._parent._json_decryptors[model] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class _EncryptDecorators:
|
||||
"""Decorators for encryption handlers.
|
||||
|
||||
Provides @encryption.encrypt.blob and @encryption.encrypt.json decorators for
|
||||
registering encryption functions.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Encryption):
|
||||
self._parent = parent
|
||||
self._json = _JsonEncryptDecorators(parent)
|
||||
|
||||
def blob(self, fn: types.BlobEncryptor) -> types.BlobEncryptor:
|
||||
"""Register a blob encryption handler.
|
||||
|
||||
The handler will be called to encrypt opaque data like checkpoint blobs.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@encryption.encrypt.blob
|
||||
async def encrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
|
||||
# Encrypt the blob using your encryption service
|
||||
return encrypted_blob
|
||||
```
|
||||
|
||||
Args:
|
||||
fn: The encryption handler function
|
||||
|
||||
Returns:
|
||||
The registered handler function
|
||||
|
||||
Raises:
|
||||
DuplicateHandlerError: If blob encryptor already registered
|
||||
TypeError: If handler has invalid signature
|
||||
"""
|
||||
if self._parent._blob_encryptor is not None:
|
||||
raise DuplicateHandlerError("Blob encryptor already registered")
|
||||
_validate_handler(fn, "Blob encryptor")
|
||||
self._parent._blob_encryptor = fn
|
||||
return fn
|
||||
|
||||
@property
|
||||
def json(self) -> _JsonEncryptDecorators:
|
||||
"""Access JSON encryption decorators.
|
||||
|
||||
Supports model-specific handlers:
|
||||
- @encryption.encrypt.json - default handler for all models
|
||||
- @encryption.encrypt.json.thread - handler for thread model only
|
||||
- @encryption.encrypt.json.assistant - handler for assistant model only
|
||||
|
||||
Example:
|
||||
```python
|
||||
@encryption.encrypt.json
|
||||
async def default_encrypt(ctx: EncryptionContext, data: dict) -> dict:
|
||||
# Default encryption for all models
|
||||
return encrypt_data(data)
|
||||
|
||||
@encryption.encrypt.json.thread
|
||||
async def encrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
|
||||
# Special encryption for thread model only
|
||||
return encrypt_thread_data(data)
|
||||
```
|
||||
"""
|
||||
return self._json
|
||||
|
||||
|
||||
class _DecryptDecorators:
|
||||
"""Decorators for decryption handlers.
|
||||
|
||||
Provides @encryption.decrypt.blob and @encryption.decrypt.json decorators for
|
||||
registering decryption functions.
|
||||
"""
|
||||
|
||||
def __init__(self, parent: Encryption):
|
||||
self._parent = parent
|
||||
self._json = _JsonDecryptDecorators(parent)
|
||||
|
||||
def blob(self, fn: types.BlobDecryptor) -> types.BlobDecryptor:
|
||||
"""Register a blob decryption handler.
|
||||
|
||||
The handler will be called to decrypt opaque data like checkpoint blobs.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@encryption.decrypt.blob
|
||||
async def decrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
|
||||
# Decrypt the blob using your encryption service
|
||||
return decrypted_blob
|
||||
```
|
||||
|
||||
Args:
|
||||
fn: The decryption handler function
|
||||
|
||||
Returns:
|
||||
The registered handler function
|
||||
|
||||
Raises:
|
||||
DuplicateHandlerError: If blob decryptor already registered
|
||||
TypeError: If handler has invalid signature
|
||||
"""
|
||||
if self._parent._blob_decryptor is not None:
|
||||
raise DuplicateHandlerError("Blob decryptor already registered")
|
||||
_validate_handler(fn, "Blob decryptor")
|
||||
self._parent._blob_decryptor = fn
|
||||
return fn
|
||||
|
||||
@property
|
||||
def json(self) -> _JsonDecryptDecorators:
|
||||
"""Access JSON decryption decorators.
|
||||
|
||||
Supports model-specific handlers:
|
||||
- @encryption.decrypt.json - default handler for all models
|
||||
- @encryption.decrypt.json.thread - handler for thread model only
|
||||
- @encryption.decrypt.json.assistant - handler for assistant model only
|
||||
|
||||
Example:
|
||||
```python
|
||||
@encryption.decrypt.json
|
||||
async def default_decrypt(ctx: EncryptionContext, data: dict) -> dict:
|
||||
# Default decryption for all models
|
||||
return decrypt_data(data)
|
||||
|
||||
@encryption.decrypt.json.thread
|
||||
async def decrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
|
||||
# Special decryption for thread model only
|
||||
return decrypt_thread_data(data)
|
||||
```
|
||||
"""
|
||||
return self._json
|
||||
|
||||
|
||||
class Encryption:
|
||||
"""Add custom at-rest encryption to your LangGraph application.
|
||||
|
||||
.. warning::
|
||||
This API is in beta and may change in future versions.
|
||||
|
||||
The Encryption class provides a system for implementing custom encryption
|
||||
of data at rest in LangGraph applications. It supports encryption of
|
||||
both opaque blobs (like checkpoints) and structured JSON data (like
|
||||
metadata, context, kwargs, values, etc.).
|
||||
|
||||
To use, create a separate Python file and add the path to the file to your
|
||||
LangGraph API configuration file (`langgraph.json`). Within that file, create
|
||||
an instance of the Encryption class and register encryption and decryption
|
||||
handlers as needed.
|
||||
|
||||
Example `langgraph.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": ["."],
|
||||
"graphs": {
|
||||
"agent": "./my_agent/agent.py:graph"
|
||||
},
|
||||
"env": ".env",
|
||||
"encryption": {
|
||||
"path": "./encryption.py:my_encryption"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then the LangGraph server will load your encryption file and use it to
|
||||
encrypt/decrypt data at rest.
|
||||
|
||||
???+ example "Basic Usage"
|
||||
|
||||
```python
|
||||
from langgraph_sdk import Encryption, EncryptionContext
|
||||
|
||||
my_encryption = Encryption()
|
||||
|
||||
@my_encryption.encrypt.blob
|
||||
async def encrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
|
||||
# Call your encryption service
|
||||
return encrypted_blob
|
||||
|
||||
@my_encryption.decrypt.blob
|
||||
async def decrypt_blob(ctx: EncryptionContext, blob: bytes) -> bytes:
|
||||
# Call your decryption service
|
||||
return decrypted_blob
|
||||
|
||||
@my_encryption.encrypt.json
|
||||
async def encrypt_json(ctx: EncryptionContext, data: dict) -> dict:
|
||||
# Practical encryption strategy:
|
||||
# - "owner" field: unencrypted (for search/filtering)
|
||||
# - "my.customer.org/" prefixed fields: encrypt VALUES only
|
||||
# - All other fields: pass through unencrypted
|
||||
encrypted = {}
|
||||
for key, value in data.items():
|
||||
if key.startswith("my.customer.org/"):
|
||||
# Encrypt VALUE for sensitive customer data
|
||||
encrypted[key] = encrypt_value(value)
|
||||
else:
|
||||
# Pass through (including "owner" for search)
|
||||
encrypted[key] = value
|
||||
return encrypted
|
||||
|
||||
@my_encryption.decrypt.json
|
||||
async def decrypt_json(ctx: EncryptionContext, data: dict) -> dict:
|
||||
# Decrypt VALUES for "my.customer.org/" prefixed fields
|
||||
decrypted = {}
|
||||
for key, value in data.items():
|
||||
if key.startswith("my.customer.org/"):
|
||||
decrypted[key] = decrypt_value(value)
|
||||
else:
|
||||
decrypted[key] = value
|
||||
return decrypted
|
||||
```
|
||||
|
||||
???+ example "Model-Specific Handlers"
|
||||
|
||||
You can register different encryption handlers for different model types
|
||||
(thread, assistant, run, cron, checkpoint, etc.):
|
||||
|
||||
```python
|
||||
from langgraph_sdk import Encryption, EncryptionContext
|
||||
|
||||
my_encryption = Encryption()
|
||||
|
||||
# Default handler for models without specific handlers
|
||||
@my_encryption.encrypt.json
|
||||
async def default_encrypt(ctx: EncryptionContext, data: dict) -> dict:
|
||||
return standard_encrypt(data)
|
||||
|
||||
# Thread-specific handler (uses different KMS key)
|
||||
@my_encryption.encrypt.json.thread
|
||||
async def encrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
|
||||
return encrypt_with_thread_key(data)
|
||||
|
||||
# Assistant-specific handler
|
||||
@my_encryption.encrypt.json.assistant
|
||||
async def encrypt_assistant(ctx: EncryptionContext, data: dict) -> dict:
|
||||
return encrypt_with_assistant_key(data)
|
||||
|
||||
# Same pattern for decryption
|
||||
@my_encryption.decrypt.json
|
||||
async def default_decrypt(ctx: EncryptionContext, data: dict) -> dict:
|
||||
return standard_decrypt(data)
|
||||
|
||||
@my_encryption.decrypt.json.thread
|
||||
async def decrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
|
||||
return decrypt_with_thread_key(data)
|
||||
```
|
||||
|
||||
???+ example "Field-Specific Logic"
|
||||
|
||||
The `ctx.field` attribute tells you which specific field is being encrypted,
|
||||
allowing different logic within the same model:
|
||||
|
||||
```python
|
||||
@my_encryption.encrypt.json.thread
|
||||
async def encrypt_thread(ctx: EncryptionContext, data: dict) -> dict:
|
||||
if ctx.field == "metadata":
|
||||
# Thread metadata - standard encryption
|
||||
return encrypt_standard(data)
|
||||
elif ctx.field == "values":
|
||||
# Thread values - more sensitive, use stronger encryption
|
||||
return encrypt_sensitive(data)
|
||||
else:
|
||||
return encrypt_standard(data)
|
||||
```
|
||||
"""
|
||||
|
||||
__slots__ = (
|
||||
"_blob_decryptor",
|
||||
"_blob_encryptor",
|
||||
"_context_handler",
|
||||
"_json_decryptor",
|
||||
"_json_decryptors",
|
||||
"_json_encryptor",
|
||||
"_json_encryptors",
|
||||
"decrypt",
|
||||
"encrypt",
|
||||
)
|
||||
|
||||
types = types
|
||||
"""Reference to encryption type definitions.
|
||||
|
||||
Provides access to all type definitions used in the encryption system,
|
||||
including EncryptionContext, BlobEncryptor, BlobDecryptor,
|
||||
JsonEncryptor, and JsonDecryptor.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the Encryption instance."""
|
||||
_warn_encryption_beta()
|
||||
self.encrypt = _EncryptDecorators(self)
|
||||
self.decrypt = _DecryptDecorators(self)
|
||||
self._blob_encryptor: types.BlobEncryptor | None = None
|
||||
self._blob_decryptor: types.BlobDecryptor | None = None
|
||||
self._json_encryptor: types.JsonEncryptor | None = None
|
||||
self._json_decryptor: types.JsonDecryptor | None = None
|
||||
self._json_encryptors: dict[str, types.JsonEncryptor] = {}
|
||||
self._json_decryptors: dict[str, types.JsonDecryptor] = {}
|
||||
self._context_handler: types.ContextHandler | None = None
|
||||
|
||||
def context(self, fn: types.ContextHandler) -> types.ContextHandler:
|
||||
"""Register a context handler to derive encryption context from auth.
|
||||
|
||||
The handler receives the authenticated user and current EncryptionContext,
|
||||
and returns a dict that becomes ctx.metadata for encrypt/decrypt handlers.
|
||||
|
||||
This allows encryption context to be derived from JWT claims or other
|
||||
auth-derived data instead of requiring a separate X-Encryption-Context header.
|
||||
|
||||
Note: The context handler is called once per request in middleware,
|
||||
so ctx.model and ctx.field will be None in the handler.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langgraph_sdk import Encryption, EncryptionContext
|
||||
from starlette.authentication import BaseUser
|
||||
|
||||
encryption = Encryption()
|
||||
|
||||
@encryption.context
|
||||
async def get_context(user: BaseUser, ctx: EncryptionContext) -> dict:
|
||||
# Derive encryption context from authenticated user
|
||||
return {
|
||||
**ctx.metadata, # preserve X-Encryption-Context header if present
|
||||
"tenant_id": user.tenant_id,
|
||||
}
|
||||
```
|
||||
|
||||
Args:
|
||||
fn: The context handler function
|
||||
|
||||
Returns:
|
||||
The registered handler function
|
||||
"""
|
||||
self._context_handler = fn
|
||||
return fn
|
||||
|
||||
def get_json_encryptor(
|
||||
self, model: str | None = None
|
||||
) -> types.JsonEncryptor | None:
|
||||
"""Get the JSON encryptor for a specific model.
|
||||
|
||||
Args:
|
||||
model: The model type (e.g., "thread", "assistant"). If None, returns default.
|
||||
|
||||
Returns:
|
||||
Model-specific encryptor if registered, otherwise default encryptor, or None.
|
||||
"""
|
||||
if model and model in self._json_encryptors:
|
||||
return self._json_encryptors[model]
|
||||
return self._json_encryptor
|
||||
|
||||
def get_json_decryptor(
|
||||
self, model: str | None = None
|
||||
) -> types.JsonDecryptor | None:
|
||||
"""Get the JSON decryptor for a specific model.
|
||||
|
||||
Args:
|
||||
model: The model type (e.g., "thread", "assistant"). If None, returns default.
|
||||
|
||||
Returns:
|
||||
Model-specific decryptor if registered, otherwise default decryptor, or None.
|
||||
"""
|
||||
if model and model in self._json_decryptors:
|
||||
return self._json_decryptors[model]
|
||||
return self._json_decryptor
|
||||
|
||||
def __repr__(self) -> str:
|
||||
handlers = []
|
||||
if self._blob_encryptor:
|
||||
handlers.append("blob_encryptor")
|
||||
if self._blob_decryptor:
|
||||
handlers.append("blob_decryptor")
|
||||
if self._json_encryptor:
|
||||
handlers.append("json_encryptor")
|
||||
if self._json_decryptor:
|
||||
handlers.append("json_decryptor")
|
||||
if self._json_encryptors:
|
||||
handlers.append(f"json_encryptors({list(self._json_encryptors.keys())})")
|
||||
if self._json_decryptors:
|
||||
handlers.append(f"json_decryptors({list(self._json_decryptors.keys())})")
|
||||
if self._context_handler:
|
||||
handlers.append("context_handler")
|
||||
return f"Encryption(handlers=[{', '.join(handlers)}])"
|
||||
@@ -0,0 +1,147 @@
|
||||
"""Encryption and decryption types for LangGraph.
|
||||
|
||||
This module defines the core types used for custom at-rest encryption
|
||||
in LangGraph. It includes context types and typed dictionaries for
|
||||
encryption operations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
Json = dict[str, typing.Any]
|
||||
"""JSON-serializable dictionary type for structured data encryption."""
|
||||
|
||||
|
||||
class EncryptionContext:
|
||||
"""Context passed to encryption/decryption handlers.
|
||||
|
||||
Contains arbitrary non-secret key-values that will be stored on encrypt.
|
||||
These key-values are intended to be sent to an external service that
|
||||
manages keys and handles the actual encryption and decryption of data.
|
||||
|
||||
Attributes:
|
||||
model: The model type being encrypted (e.g., "assistant", "thread", "run", "checkpoint")
|
||||
field: The specific field being encrypted (e.g., "metadata", "context", "kwargs", "values")
|
||||
metadata: Additional context metadata that can be used for encryption decisions
|
||||
"""
|
||||
|
||||
__slots__ = ("field", "metadata", "model")
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str | None = None,
|
||||
metadata: dict[str, typing.Any] | None = None,
|
||||
field: str | None = None,
|
||||
):
|
||||
self.model = model
|
||||
self.field = field
|
||||
self.metadata = metadata or {}
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"EncryptionContext(model={self.model!r}, field={self.field!r}, metadata={self.metadata!r})"
|
||||
|
||||
|
||||
BlobEncryptor = Callable[[EncryptionContext, bytes], Awaitable[bytes]]
|
||||
"""Handler for encrypting opaque blob data like checkpoints.
|
||||
|
||||
Note: Must be an async function. Encryption typically involves I/O operations
|
||||
(calling external KMS services), which should be async.
|
||||
|
||||
Args:
|
||||
ctx: Encryption context with model type and metadata
|
||||
blob: The raw bytes to encrypt
|
||||
|
||||
Returns:
|
||||
Awaitable that resolves to encrypted bytes
|
||||
"""
|
||||
|
||||
BlobDecryptor = Callable[[EncryptionContext, bytes], Awaitable[bytes]]
|
||||
"""Handler for decrypting opaque blob data like checkpoints.
|
||||
|
||||
Note: Must be an async function. Decryption typically involves I/O operations
|
||||
(calling external KMS services), which should be async.
|
||||
|
||||
Args:
|
||||
ctx: Encryption context with model type and metadata
|
||||
blob: The encrypted bytes to decrypt
|
||||
|
||||
Returns:
|
||||
Awaitable that resolves to decrypted bytes
|
||||
"""
|
||||
|
||||
JsonEncryptor = Callable[[EncryptionContext, Json], Awaitable[Json]]
|
||||
"""Handler for encrypting structured JSON data.
|
||||
|
||||
Note: Must be an async function. Encryption typically involves I/O operations
|
||||
(calling external KMS services), which should be async.
|
||||
|
||||
Used for encrypting structured data like metadata, context, kwargs, values,
|
||||
and other JSON-serializable fields across different model types.
|
||||
|
||||
Maps plaintext fields to encrypted fields. A practical approach:
|
||||
- Keep "owner" field unencrypted for search/filtering
|
||||
- Encrypt VALUES (not keys) for fields with specific prefix (e.g., "my.customer.org/")
|
||||
- Pass through all other fields unencrypted
|
||||
|
||||
Example:
|
||||
Input: {"owner": "user123", "my.customer.org/email": "john@example.com", "tenant_id": "t-456"}
|
||||
Output: {"owner": "user123", "my.customer.org/email": "ENCRYPTED", "tenant_id": "t-456"}
|
||||
|
||||
Note: Encrypted field VALUES cannot be reliably searched, as most real-world
|
||||
encryption implementations use nonces (non-deterministic encryption).
|
||||
Only unencrypted fields can be used in search queries.
|
||||
|
||||
Args:
|
||||
ctx: Encryption context with model type, field name, and metadata
|
||||
data: The plaintext JSON dictionary
|
||||
|
||||
Returns:
|
||||
Awaitable that resolves to encrypted JSON dictionary
|
||||
"""
|
||||
|
||||
JsonDecryptor = Callable[[EncryptionContext, Json], Awaitable[Json]]
|
||||
"""Handler for decrypting structured JSON data.
|
||||
|
||||
Note: Must be an async function. Decryption typically involves I/O operations
|
||||
(calling external KMS services), which should be async.
|
||||
|
||||
Inverse of JsonEncryptor. Must be able to decrypt data that
|
||||
was encrypted by the corresponding encryptor.
|
||||
|
||||
Args:
|
||||
ctx: Encryption context with model type, field name, and metadata
|
||||
data: The encrypted JSON dictionary
|
||||
|
||||
Returns:
|
||||
Awaitable that resolves to decrypted JSON dictionary
|
||||
"""
|
||||
|
||||
if typing.TYPE_CHECKING:
|
||||
from starlette.authentication import BaseUser
|
||||
|
||||
ContextHandler = Callable[
|
||||
["BaseUser", EncryptionContext], Awaitable[dict[str, typing.Any]]
|
||||
]
|
||||
"""Handler for deriving encryption context from authenticated user info.
|
||||
|
||||
Note: Must be an async function as it may involve I/O operations.
|
||||
|
||||
The context handler is called once per request in middleware (after auth),
|
||||
allowing encryption context to be derived from JWT claims, user properties,
|
||||
or other auth-derived data instead of requiring a separate X-Encryption-Context header.
|
||||
|
||||
The return value becomes ctx.metadata for subsequent encrypt/decrypt operations
|
||||
and is persisted with encrypted data for later decryption.
|
||||
|
||||
Note: ctx.model and ctx.field will be None in context handlers since
|
||||
the handler runs once per request before any specific model/field is known.
|
||||
|
||||
Args:
|
||||
user: The authenticated user (from Starlette's AuthenticationMiddleware)
|
||||
ctx: Current encryption context with metadata from X-Encryption-Context header
|
||||
|
||||
Returns:
|
||||
Awaitable that resolves to dict that becomes the new ctx.metadata
|
||||
"""
|
||||
@@ -3,15 +3,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from dataclasses import Field
|
||||
from datetime import datetime
|
||||
from typing import (
|
||||
Any,
|
||||
ClassVar,
|
||||
Literal,
|
||||
NamedTuple,
|
||||
Protocol,
|
||||
TypeAlias,
|
||||
TypedDict,
|
||||
Union,
|
||||
)
|
||||
|
||||
from typing_extensions import TypedDict
|
||||
|
||||
Json = dict[str, Any] | None
|
||||
"""Represents a JSON-like structure, which can be None or a dictionary with string keys and any values."""
|
||||
|
||||
@@ -143,8 +148,6 @@ SortOrder = Literal["asc", "desc"]
|
||||
The order to sort by.
|
||||
"""
|
||||
|
||||
Context: TypeAlias = dict[str, Any]
|
||||
|
||||
|
||||
class Config(TypedDict, total=False):
|
||||
"""Configuration options for a call."""
|
||||
@@ -243,6 +246,15 @@ class Assistant(AssistantBase):
|
||||
"""The last time the assistant was updated."""
|
||||
|
||||
|
||||
class AssistantsSearchResponse(TypedDict):
|
||||
"""Paginated response for assistant search results."""
|
||||
|
||||
assistants: list[Assistant]
|
||||
"""The assistants returned for the current search page."""
|
||||
next: str | None
|
||||
"""Pagination cursor from the ``X-Pagination-Next`` response header."""
|
||||
|
||||
|
||||
class Interrupt(TypedDict):
|
||||
"""Represents an interruption in the execution flow."""
|
||||
|
||||
@@ -559,3 +571,54 @@ class RunCreateMetadata(TypedDict):
|
||||
|
||||
thread_id: str | None
|
||||
"""The ID of the thread."""
|
||||
|
||||
|
||||
class _TypedDictLikeV1(Protocol):
|
||||
"""Protocol to represent types that behave like TypedDicts
|
||||
|
||||
Version 1: using `ClassVar` for keys."""
|
||||
|
||||
__required_keys__: ClassVar[frozenset[str]]
|
||||
__optional_keys__: ClassVar[frozenset[str]]
|
||||
|
||||
|
||||
class _TypedDictLikeV2(Protocol):
|
||||
"""Protocol to represent types that behave like TypedDicts
|
||||
|
||||
Version 2: not using `ClassVar` for keys."""
|
||||
|
||||
__required_keys__: frozenset[str]
|
||||
__optional_keys__: frozenset[str]
|
||||
|
||||
|
||||
class _DataclassLike(Protocol):
|
||||
"""Protocol to represent types that behave like dataclasses.
|
||||
|
||||
Inspired by the private _DataclassT from dataclasses that uses a similar protocol as a bound.
|
||||
"""
|
||||
|
||||
__dataclass_fields__: ClassVar[dict[str, Field[Any]]]
|
||||
|
||||
|
||||
class _BaseModelLike(Protocol):
|
||||
"""Protocol to represent types that behave like Pydantic `BaseModel`."""
|
||||
|
||||
model_config: ClassVar[dict[str, Any]]
|
||||
__pydantic_core_schema__: ClassVar[Any]
|
||||
|
||||
def model_dump(
|
||||
self,
|
||||
**kwargs: Any,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
_JSONLike: TypeAlias = None | str | int | float | bool
|
||||
_JSONMap: TypeAlias = Mapping[
|
||||
str, Union[_JSONLike, list[_JSONLike], "_JSONMap", list["_JSONMap"]]
|
||||
]
|
||||
|
||||
Input: TypeAlias = (
|
||||
_TypedDictLikeV1 | _TypedDictLikeV2 | _DataclassLike | _BaseModelLike | _JSONMap
|
||||
)
|
||||
|
||||
Context: TypeAlias = Input
|
||||
|
||||
@@ -23,8 +23,19 @@ Slack = "https://www.langchain.com/join-community"
|
||||
Reddit = "https://www.reddit.com/r/LangChain/"
|
||||
|
||||
[dependency-groups]
|
||||
test = ["pytest", "pytest-asyncio", "pytest-mock", "pytest-watch"]
|
||||
lint = ["ruff", "codespell", "mypy"]
|
||||
test = [
|
||||
"pytest",
|
||||
"pytest-asyncio",
|
||||
"pytest-mock",
|
||||
"pytest-watch",
|
||||
]
|
||||
lint = [
|
||||
"ruff==0.14.7",
|
||||
"codespell",
|
||||
"mypy==1.19.0",
|
||||
"ty==0.0.1a27",
|
||||
"starlette",
|
||||
]
|
||||
dev = [
|
||||
{ include-group = "test" },
|
||||
{ include-group = "lint" },
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk.client import (
|
||||
AssistantsClient,
|
||||
HttpClient,
|
||||
SyncAssistantsClient,
|
||||
SyncHttpClient,
|
||||
)
|
||||
|
||||
|
||||
def _assistant_payload() -> dict[str, object]:
|
||||
return {
|
||||
"assistant_id": "asst_123",
|
||||
"graph_id": "graph_123",
|
||||
"config": {"configurable": {"foo": "bar"}},
|
||||
"context": {"foo": "bar"},
|
||||
"created_at": "2024-01-01T00:00:00Z",
|
||||
"metadata": {"env": "test"},
|
||||
"version": 1,
|
||||
"name": "My Assistant",
|
||||
"description": "Example",
|
||||
"updated_at": "2024-01-02T00:00:00Z",
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistants_search_returns_list_by_default():
|
||||
assistant = _assistant_payload()
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/assistants/search"
|
||||
return httpx.Response(200, json=[assistant])
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
http_client = HttpClient(client)
|
||||
assistants_client = AssistantsClient(http_client)
|
||||
result = await assistants_client.search(limit=3)
|
||||
|
||||
assert result == [assistant]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_assistants_search_can_return_object_with_pagination_metadata():
|
||||
assistant = _assistant_payload()
|
||||
|
||||
async def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/assistants/search"
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Pagination-Next": "42"},
|
||||
json=[assistant],
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="https://example.com"
|
||||
) as client:
|
||||
http_client = HttpClient(client)
|
||||
assistants_client = AssistantsClient(http_client)
|
||||
result = await assistants_client.search(response_format="object")
|
||||
|
||||
assert result == {"assistants": [assistant], "next": "42"}
|
||||
|
||||
|
||||
def test_sync_assistants_search_can_return_object_with_pagination_metadata():
|
||||
assistant = _assistant_payload()
|
||||
|
||||
def handler(request: httpx.Request) -> httpx.Response:
|
||||
assert request.method == "POST"
|
||||
assert request.url.path == "/assistants/search"
|
||||
return httpx.Response(
|
||||
200,
|
||||
headers={"X-Pagination-Next": "84"},
|
||||
json=[assistant],
|
||||
)
|
||||
|
||||
transport = httpx.MockTransport(handler)
|
||||
with httpx.Client(transport=transport, base_url="https://example.com") as client:
|
||||
http_client = SyncHttpClient(client)
|
||||
assistants_client = SyncAssistantsClient(http_client)
|
||||
result = assistants_client.search(response_format="object")
|
||||
|
||||
assert result == {"assistants": [assistant], "next": "84"}
|
||||
@@ -0,0 +1,92 @@
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk.encryption import DuplicateHandlerError, Encryption
|
||||
|
||||
|
||||
class TestHandlerValidation:
|
||||
"""Test duplicate handler and signature validation."""
|
||||
|
||||
def test_duplicate_handlers_raise_error(self):
|
||||
"""Registering the same handler type twice raises DuplicateHandlerError."""
|
||||
encryption = Encryption()
|
||||
|
||||
@encryption.encrypt.blob
|
||||
async def blob_enc(_ctx, data):
|
||||
return data
|
||||
|
||||
@encryption.decrypt.blob
|
||||
async def blob_dec(_ctx, data):
|
||||
return data
|
||||
|
||||
@encryption.encrypt.json
|
||||
async def json_enc(_ctx, data):
|
||||
return data
|
||||
|
||||
@encryption.decrypt.json
|
||||
async def json_dec(_ctx, data):
|
||||
return data
|
||||
|
||||
@encryption.encrypt.json.thread
|
||||
async def thread_enc(_ctx, data):
|
||||
return data
|
||||
|
||||
@encryption.decrypt.json.custom
|
||||
async def custom_dec(_ctx, data):
|
||||
return data
|
||||
|
||||
# All duplicates should raise
|
||||
with pytest.raises(DuplicateHandlerError):
|
||||
|
||||
@encryption.encrypt.blob
|
||||
async def dup(_ctx, data):
|
||||
return data
|
||||
|
||||
with pytest.raises(DuplicateHandlerError):
|
||||
|
||||
@encryption.decrypt.blob
|
||||
async def dup(_ctx, data):
|
||||
return data
|
||||
|
||||
with pytest.raises(DuplicateHandlerError):
|
||||
|
||||
@encryption.encrypt.json
|
||||
async def dup(_ctx, data):
|
||||
return data
|
||||
|
||||
with pytest.raises(DuplicateHandlerError):
|
||||
|
||||
@encryption.decrypt.json
|
||||
async def dup(_ctx, data):
|
||||
return data
|
||||
|
||||
with pytest.raises(DuplicateHandlerError):
|
||||
|
||||
@encryption.encrypt.json.thread
|
||||
async def dup(_ctx, data):
|
||||
return data
|
||||
|
||||
with pytest.raises(DuplicateHandlerError):
|
||||
|
||||
@encryption.decrypt.json.custom
|
||||
async def dup(_ctx, data):
|
||||
return data
|
||||
|
||||
def test_handlers_must_be_async(self):
|
||||
"""Sync functions raise TypeError."""
|
||||
encryption = Encryption()
|
||||
|
||||
with pytest.raises(TypeError, match="must be an async function"):
|
||||
|
||||
@encryption.encrypt.blob
|
||||
def sync_handler(_ctx, data):
|
||||
return data
|
||||
|
||||
def test_handlers_must_have_two_params(self):
|
||||
"""Wrong parameter count raises TypeError."""
|
||||
encryption = Encryption()
|
||||
|
||||
with pytest.raises(TypeError, match="must accept exactly 2 parameters"):
|
||||
|
||||
@encryption.encrypt.blob # type: ignore[arg-type]
|
||||
async def wrong_params(ctx):
|
||||
return ctx
|
||||
@@ -30,6 +30,28 @@ async def test_serde_pydantic():
|
||||
assert result["name"] == "test"
|
||||
assert result["age"] == 25
|
||||
|
||||
nested_result = await _serde_roundtrip({"data": model})
|
||||
assert nested_result["data"]["name"] == "test"
|
||||
assert nested_result["data"]["age"] == 25
|
||||
|
||||
|
||||
async def test_serde_dataclass():
|
||||
from dataclasses import dataclass
|
||||
|
||||
@dataclass
|
||||
class TestDataClass:
|
||||
name: str
|
||||
age: int
|
||||
|
||||
data = TestDataClass(name="test", age=25)
|
||||
result = await _serde_roundtrip(data)
|
||||
assert result["name"] == "test"
|
||||
assert result["age"] == 25
|
||||
|
||||
nested_result = await _serde_roundtrip({"data": data})
|
||||
assert nested_result["data"]["name"] == "test"
|
||||
assert nested_result["data"]["age"] == 25
|
||||
|
||||
|
||||
async def test_serde_pydantic_cls_fails():
|
||||
# Test that serialization fails gracefully for Pydantic model when not available
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import runtime_checkable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from langgraph_sdk.schema import (
|
||||
_BaseModelLike,
|
||||
_DataclassLike,
|
||||
)
|
||||
|
||||
|
||||
def rc(cls: type) -> type:
|
||||
return runtime_checkable(cls)
|
||||
|
||||
|
||||
class MyModel(BaseModel):
|
||||
foo: str
|
||||
|
||||
|
||||
def test_base_model_like():
|
||||
assert isinstance(MyModel(foo="test"), rc(_BaseModelLike))
|
||||
|
||||
|
||||
@dataclass
|
||||
class MyDataclass:
|
||||
foo: str
|
||||
|
||||
|
||||
def test_dataclass_like():
|
||||
assert isinstance(MyDataclass(foo="test"), rc(_DataclassLike))
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Tests for api_key parameter behavior."""
|
||||
|
||||
import pytest
|
||||
|
||||
from langgraph_sdk import get_client, get_sync_client
|
||||
|
||||
|
||||
class TestSkipAutoLoadApiKey:
|
||||
"""Test the api_key parameter's auto-loading behavior."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_loads_from_env_by_default(self, monkeypatch):
|
||||
"""Test that API key is loaded from environment by default."""
|
||||
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
|
||||
|
||||
client = get_client(url="http://localhost:8123")
|
||||
assert "x-api-key" in client.http.client.headers
|
||||
assert client.http.client.headers["x-api-key"] == "test-key-from-env"
|
||||
await client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_skips_env_when_sentinel_used(self, monkeypatch):
|
||||
"""Test that API key is not loaded from environment when None is explicitly passed."""
|
||||
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
|
||||
|
||||
client = get_client(url="http://localhost:8123", api_key=None)
|
||||
assert "x-api-key" not in client.http.client.headers
|
||||
await client.aclose()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_uses_explicit_key_when_provided(self, monkeypatch):
|
||||
"""Test that explicit API key takes precedence over environment."""
|
||||
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
|
||||
|
||||
client = get_client(
|
||||
url="http://localhost:8123",
|
||||
api_key="explicit-key",
|
||||
)
|
||||
assert "x-api-key" in client.http.client.headers
|
||||
assert client.http.client.headers["x-api-key"] == "explicit-key"
|
||||
await client.aclose()
|
||||
|
||||
def test_get_sync_client_loads_from_env_by_default(self, monkeypatch):
|
||||
"""Test that sync client loads API key from environment by default."""
|
||||
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
|
||||
|
||||
client = get_sync_client(url="http://localhost:8123")
|
||||
assert "x-api-key" in client.http.client.headers
|
||||
assert client.http.client.headers["x-api-key"] == "test-key-from-env"
|
||||
client.close()
|
||||
|
||||
def test_get_sync_client_skips_env_when_sentinel_used(self, monkeypatch):
|
||||
"""Test that sync client doesn't load from environment when None is explicitly passed."""
|
||||
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
|
||||
|
||||
client = get_sync_client(url="http://localhost:8123", api_key=None)
|
||||
assert "x-api-key" not in client.http.client.headers
|
||||
client.close()
|
||||
|
||||
def test_get_sync_client_uses_explicit_key_when_provided(self, monkeypatch):
|
||||
"""Test that sync client uses explicit API key when provided."""
|
||||
monkeypatch.setenv("LANGGRAPH_API_KEY", "test-key-from-env")
|
||||
|
||||
client = get_sync_client(
|
||||
url="http://localhost:8123",
|
||||
api_key="explicit-key",
|
||||
)
|
||||
assert "x-api-key" in client.http.client.headers
|
||||
assert client.http.client.headers["x-api-key"] == "explicit-key"
|
||||
client.close()
|
||||
Generated
+186
-67
@@ -13,17 +13,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anyio"
|
||||
version = "4.11.0"
|
||||
version = "4.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
|
||||
{ name = "idna" },
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/16/ce/8a777047513153587e5434fd752e89334ac33e379aa3497db860eeb60377/anyio-4.12.0.tar.gz", hash = "sha256:73c693b567b0c55130c104d0b43a9baf3aa6a31fc6110116509f27bf75e21ec0", size = 228266, upload-time = "2025-11-28T23:37:38.911Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/9c/36c5c37947ebfb8c7f22e0eb6e4d188ee2d53aa3880f3f2744fb894f0cb1/anyio-4.12.0-py3-none-any.whl", hash = "sha256:dad2376a628f98eeca4881fc56cd06affd18f659b17a747d3ff0307ced94b1bb", size = 113362, upload-time = "2025-11-28T23:36:57.897Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -70,14 +69,14 @@ sdist = { url = "https://files.pythonhosted.org/packages/a2/55/8f8cab2afd404cf57
|
||||
|
||||
[[package]]
|
||||
name = "exceptiongroup"
|
||||
version = "1.3.0"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/9f/a65090624ecf468cdca03533906e7c69ed7588582240cfe7cc9e770b50eb/exceptiongroup-1.3.0.tar.gz", hash = "sha256:b241f5885f560bc56a59ee63ca4c6a8bfa46ae4ad651af316d4e81817bb9fd88", size = 29749, upload-time = "2025-05-10T17:42:51.123Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl", hash = "sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size = 16674, upload-time = "2025-05-10T17:42:49.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -153,11 +152,15 @@ dev = [
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-watch" },
|
||||
{ name = "ruff" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy" },
|
||||
{ name = "ruff" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
@@ -175,18 +178,22 @@ requires-dist = [
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy" },
|
||||
{ name = "mypy", specifier = "==1.19.0" },
|
||||
{ name = "pydantic", specifier = ">=2.12.4" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-asyncio" },
|
||||
{ name = "pytest-mock" },
|
||||
{ name = "pytest-watch" },
|
||||
{ name = "ruff" },
|
||||
{ name = "ruff", specifier = "==0.14.7" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.1a27" },
|
||||
]
|
||||
lint = [
|
||||
{ name = "codespell" },
|
||||
{ name = "mypy" },
|
||||
{ name = "ruff" },
|
||||
{ name = "mypy", specifier = "==1.19.0" },
|
||||
{ name = "ruff", specifier = "==0.14.7" },
|
||||
{ name = "starlette" },
|
||||
{ name = "ty", specifier = "==0.0.1a27" },
|
||||
]
|
||||
test = [
|
||||
{ name = "pytest" },
|
||||
@@ -195,49 +202,123 @@ test = [
|
||||
{ name = "pytest-watch" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "librt"
|
||||
version = "0.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/37/c3/cdff3c10e2e608490dc0a310ccf11ba777b3943ad4fcead2a2ade98c21e1/librt-0.6.3.tar.gz", hash = "sha256:c724a884e642aa2bbad52bb0203ea40406ad742368a5f90da1b220e970384aae", size = 54209, upload-time = "2025-11-29T14:01:56.058Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/84/859df8db21dedab2538ddfbe1d486dda3eb66a98c6ad7ba754a99e25e45e/librt-0.6.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:45660d26569cc22ed30adf583389d8a0d1b468f8b5e518fcf9bfe2cd298f9dd1", size = 27294, upload-time = "2025-11-29T14:00:35.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/01/ec3971cf9c4f827f17de6729bdfdbf01a67493147334f4ef8fac68936e3a/librt-0.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:54f3b2177fb892d47f8016f1087d21654b44f7fc4cf6571c1c6b3ea531ab0fcf", size = 27635, upload-time = "2025-11-29T14:00:36.496Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/f9/3efe201df84dd26388d2e0afa4c4dc668c8e406a3da7b7319152faf835a1/librt-0.6.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c5b31bed2c2f2fa1fcb4815b75f931121ae210dc89a3d607fb1725f5907f1437", size = 81768, upload-time = "2025-11-29T14:00:37.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/13/f63e60bc219b17f3d8f3d13423cd4972e597b0321c51cac7bfbdd5e1f7b9/librt-0.6.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f8ed5053ef9fb08d34f1fd80ff093ccbd1f67f147633a84cf4a7d9b09c0f089", size = 85884, upload-time = "2025-11-29T14:00:38.433Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/42/0068f14f39a79d1ce8a19d4988dd07371df1d0a7d3395fbdc8a25b1c9437/librt-0.6.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3f0e4bd9bcb0ee34fa3dbedb05570da50b285f49e52c07a241da967840432513", size = 85830, upload-time = "2025-11-29T14:00:39.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1c/87f5af3a9e6564f09e50c72f82fc3057fd42d1facc8b510a707d0438c4ad/librt-0.6.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d8f89c8d20dfa648a3f0a56861946eb00e5b00d6b00eea14bc5532b2fcfa8ef1", size = 88086, upload-time = "2025-11-29T14:00:40.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e5/22153b98b88a913b5b3f266f12e57df50a2a6960b3f8fcb825b1a0cfe40a/librt-0.6.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:ecc2c526547eacd20cb9fbba19a5268611dbc70c346499656d6cf30fae328977", size = 86470, upload-time = "2025-11-29T14:00:41.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/3c/ea1edb587799b1edcc22444e0630fa422e32d7aaa5bfb5115b948acc2d1c/librt-0.6.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:fbedeb9b48614d662822ee514567d2d49a8012037fc7b4cd63f282642c2f4b7d", size = 89079, upload-time = "2025-11-29T14:00:42.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/ad/50bb4ae6b07c9f3ab19653e0830a210533b30eb9a18d515efb5a2b9d0c7c/librt-0.6.3-cp310-cp310-win32.whl", hash = "sha256:0765b0fe0927d189ee14b087cd595ae636bef04992e03fe6dfdaa383866c8a46", size = 19820, upload-time = "2025-11-29T14:00:44.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/12/7426ee78f3b1dbe11a90619d54cb241ca924ca3c0ff9ade3992178e9b440/librt-0.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:8c659f9fb8a2f16dc4131b803fa0144c1dadcb3ab24bb7914d01a6da58ae2457", size = 21332, upload-time = "2025-11-29T14:00:45.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/80/bc60fd16fe24910bf5974fb914778a2e8540cef55385ab2cb04a0dfe42c4/librt-0.6.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:61348cc488b18d1b1ff9f3e5fcd5ac43ed22d3e13e862489d2267c2337285c08", size = 27285, upload-time = "2025-11-29T14:00:46.626Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/3c/26335536ed9ba097c79cffcee148393592e55758fe76d99015af3e47a6d0/librt-0.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:64645b757d617ad5f98c08e07620bc488d4bced9ced91c6279cec418f16056fa", size = 27629, upload-time = "2025-11-29T14:00:47.863Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/fd/2dcedeacfedee5d2eda23e7a49c1c12ce6221b5d58a13555f053203faafc/librt-0.6.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:26b8026393920320bb9a811b691d73c5981385d537ffc5b6e22e53f7b65d4122", size = 82039, upload-time = "2025-11-29T14:00:49.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/ff/6aa11914b83b0dc2d489f7636942a8e3322650d0dba840db9a1b455f3caa/librt-0.6.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d998b432ed9ffccc49b820e913c8f327a82026349e9c34fa3690116f6b70770f", size = 86560, upload-time = "2025-11-29T14:00:50.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/a1/d25af61958c2c7eb978164aeba0350719f615179ba3f428b682b9a5fdace/librt-0.6.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e18875e17ef69ba7dfa9623f2f95f3eda6f70b536079ee6d5763ecdfe6cc9040", size = 86494, upload-time = "2025-11-29T14:00:51.383Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/4b/40e75d3b258c801908e64b39788f9491635f9554f8717430a491385bd6f2/librt-0.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a218f85081fc3f70cddaed694323a1ad7db5ca028c379c214e3a7c11c0850523", size = 88914, upload-time = "2025-11-29T14:00:52.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/6d/0070c81aba8a169224301c75fb5fb6c3c25ca67e6ced086584fc130d5a67/librt-0.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ef42ff4edd369e84433ce9b188a64df0837f4f69e3d34d3b34d4955c599d03f", size = 86944, upload-time = "2025-11-29T14:00:53.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/94/809f38887941b7726692e0b5a083dbdc87dbb8cf893e3b286550c5f0b129/librt-0.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e0f2b79993fec23a685b3e8107ba5f8675eeae286675a216da0b09574fa1e47", size = 89852, upload-time = "2025-11-29T14:00:54.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/a3/b0e5b1cda675b91f1111d8ba941da455d8bfaa22f4d2d8963ba96ccb5b12/librt-0.6.3-cp311-cp311-win32.whl", hash = "sha256:fd98cacf4e0fabcd4005c452cb8a31750258a85cab9a59fb3559e8078da408d7", size = 19948, upload-time = "2025-11-29T14:00:55.989Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/73/70011c2b37e3be3ece3affd3abc8ebe5cda482b03fd6b3397906321a901e/librt-0.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:e17b5b42c8045867ca9d1f54af00cc2275198d38de18545edaa7833d7e9e4ac8", size = 21406, upload-time = "2025-11-29T14:00:56.874Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/ee/119aa759290af6ca0729edf513ca390c1afbeae60f3ecae9b9d56f25a8a9/librt-0.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:87597e3d57ec0120a3e1d857a708f80c02c42ea6b00227c728efbc860f067c45", size = 20875, upload-time = "2025-11-29T14:00:57.752Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/2c/b59249c566f98fe90e178baf59e83f628d6c38fb8bc78319301fccda0b5e/librt-0.6.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:74418f718083009108dc9a42c21bf2e4802d49638a1249e13677585fcc9ca176", size = 27841, upload-time = "2025-11-29T14:00:58.925Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/e8/9db01cafcd1a2872b76114c858f81cc29ce7ad606bc102020d6dabf470fb/librt-0.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:514f3f363d1ebc423357d36222c37e5c8e6674b6eae8d7195ac9a64903722057", size = 27844, upload-time = "2025-11-29T14:01:00.2Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/4d/da449d3a7d83cc853af539dee42adc37b755d7eea4ad3880bacfd84b651d/librt-0.6.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf1115207a5049d1f4b7b4b72de0e52f228d6c696803d94843907111cbf80610", size = 84091, upload-time = "2025-11-29T14:01:01.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/6c/f90306906fb6cc6eaf4725870f0347115de05431e1f96d35114392d31fda/librt-0.6.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ad8ba80cdcea04bea7b78fcd4925bfbf408961e9d8397d2ee5d3ec121e20c08c", size = 88239, upload-time = "2025-11-29T14:01:02.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/ae/473ce7b423cfac2cb503851a89d9d2195bf615f534d5912bf86feeebbee7/librt-0.6.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4018904c83eab49c814e2494b4e22501a93cdb6c9f9425533fe693c3117126f9", size = 88815, upload-time = "2025-11-29T14:01:03.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/6d/934df738c87fb9617cabefe4891eece585a06abe6def25b4bca3b174429d/librt-0.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8983c5c06ac9c990eac5eb97a9f03fe41dc7e9d7993df74d9e8682a1056f596c", size = 90598, upload-time = "2025-11-29T14:01:04.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/89/eeaa124f5e0f431c2b39119550378ae817a4b1a3c93fd7122f0639336fff/librt-0.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d7769c579663a6f8dbf34878969ac71befa42067ce6bf78e6370bf0d1194997c", size = 88603, upload-time = "2025-11-29T14:01:05.02Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/ed/c60b3c1cfc27d709bc0288af428ce58543fcb5053cf3eadbc773c24257f5/librt-0.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d3c9a07eafdc70556f8c220da4a538e715668c0c63cabcc436a026e4e89950bf", size = 92112, upload-time = "2025-11-29T14:01:06.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/ab/f56169be5f716ef4ab0277be70bcb1874b4effc262e655d85b505af4884d/librt-0.6.3-cp312-cp312-win32.whl", hash = "sha256:38320386a48a15033da295df276aea93a92dfa94a862e06893f75ea1d8bbe89d", size = 20127, upload-time = "2025-11-29T14:01:07.283Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/8d/222750ce82bf95125529eaab585ac7e2829df252f3cfc05d68792fb1dd2c/librt-0.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:c0ecf4786ad0404b072196b5df774b1bb23c8aacdcacb6c10b4128bc7b00bd01", size = 21545, upload-time = "2025-11-29T14:01:08.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c9/f731ddcfb72f446a92a8674c6b8e1e2242773cce43a04f41549bd8b958ff/librt-0.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:9f2a6623057989ebc469cd9cc8fe436c40117a0147627568d03f84aef7854c55", size = 20946, upload-time = "2025-11-29T14:01:09.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/aa/3055dd440f8b8b3b7e8624539a0749dd8e1913e978993bcca9ce7e306231/librt-0.6.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e716f9012148a81f02f46a04fc4c663420c6fbfeacfac0b5e128cf43b4413d3", size = 27874, upload-time = "2025-11-29T14:01:10.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/93/226d7dd455eaa4c26712b5ccb2dfcca12831baa7f898c8ffd3a831e29fda/librt-0.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:669ff2495728009a96339c5ad2612569c6d8be4474e68f3f3ac85d7c3261f5f5", size = 27852, upload-time = "2025-11-29T14:01:11.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/8b/db9d51191aef4e4cc06285250affe0bb0ad8b2ed815f7ca77951655e6f02/librt-0.6.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:349b6873ebccfc24c9efd244e49da9f8a5c10f60f07575e248921aae2123fc42", size = 84264, upload-time = "2025-11-29T14:01:12.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/53/297c96bda3b5a73bdaf748f1e3ae757edd29a0a41a956b9c10379f193417/librt-0.6.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c74c26736008481c9f6d0adf1aedb5a52aff7361fea98276d1f965c0256ee70", size = 88432, upload-time = "2025-11-29T14:01:13.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/3a/c005516071123278e340f22de72fa53d51e259d49215295c212da16c4dc2/librt-0.6.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:408a36ddc75e91918cb15b03460bdc8a015885025d67e68c6f78f08c3a88f522", size = 89014, upload-time = "2025-11-29T14:01:14.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/9b/ea715f818d926d17b94c80a12d81a79e95c44f52848e61e8ca1ff29bb9a9/librt-0.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e61ab234624c9ffca0248a707feffe6fac2343758a36725d8eb8a6efef0f8c30", size = 90807, upload-time = "2025-11-29T14:01:15.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/fc/4e2e4c87e002fa60917a8e474fd13c4bac9a759df82be3778573bb1ab954/librt-0.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:324462fe7e3896d592b967196512491ec60ca6e49c446fe59f40743d08c97917", size = 88890, upload-time = "2025-11-29T14:01:16.633Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/7f/c7428734fbdfd4db3d5b9237fc3a857880b2ace66492836f6529fef25d92/librt-0.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:36b2ec8c15030002c7f688b4863e7be42820d7c62d9c6eece3db54a2400f0530", size = 92300, upload-time = "2025-11-29T14:01:17.658Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/0c/738c4824fdfe74dc0f95d5e90ef9e759d4ecf7fd5ba964d54a7703322251/librt-0.6.3-cp313-cp313-win32.whl", hash = "sha256:25b1b60cb059471c0c0c803e07d0dfdc79e41a0a122f288b819219ed162672a3", size = 20159, upload-time = "2025-11-29T14:01:18.61Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/95/93d0e61bc617306ecf4c54636b5cbde4947d872563565c4abdd9d07a39d3/librt-0.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:10a95ad074e2a98c9e4abc7f5b7d40e5ecbfa84c04c6ab8a70fabf59bd429b88", size = 21484, upload-time = "2025-11-29T14:01:19.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/23/abd7ace79ab54d1dbee265f13529266f686a7ce2d21ab59a992f989009b6/librt-0.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:17000df14f552e86877d67e4ab7966912224efc9368e998c96a6974a8d609bf9", size = 20935, upload-time = "2025-11-29T14:01:20.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/14/c06cb31152182798ed98be73f54932ab984894f5a8fccf9b73130897a938/librt-0.6.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8e695f25d1a425ad7a272902af8ab8c8d66c1998b177e4b5f5e7b4e215d0c88a", size = 27566, upload-time = "2025-11-29T14:01:21.609Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/ce83ca7b057b06150519152f53a0b302d7c33c8692ce2f01f669b5a819d9/librt-0.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3e84a4121a7ae360ca4da436548a9c1ca8ca134a5ced76c893cc5944426164bd", size = 27753, upload-time = "2025-11-29T14:01:22.558Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/ec/739a885ef0a2839b6c25f1b01c99149d2cb6a34e933ffc8c051fcd22012e/librt-0.6.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05f385a414de3f950886ea0aad8f109650d4b712cf9cc14cc17f5f62a9ab240b", size = 83178, upload-time = "2025-11-29T14:01:23.555Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/bd/dc18bb1489d48c0911b9f4d72eae2d304ea264e215ba80f1e6ba4a9fc41d/librt-0.6.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36a8e337461150b05ca2c7bdedb9e591dfc262c5230422cea398e89d0c746cdc", size = 87266, upload-time = "2025-11-29T14:01:24.532Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/f3/d0c5431b39eef15e48088b2d739ad84b17c2f1a22c0345c6d4c4a42b135e/librt-0.6.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dcbe48f6a03979384f27086484dc2a14959be1613cb173458bd58f714f2c48f3", size = 87623, upload-time = "2025-11-29T14:01:25.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/15/9a52e90834e4bd6ee16cdbaf551cb32227cbaad27398391a189c489318bc/librt-0.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4bca9e4c260233fba37b15c4ec2f78aa99c1a79fbf902d19dd4a763c5c3fb751", size = 89436, upload-time = "2025-11-29T14:01:26.769Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/8a/a7e78e46e8486e023c50f21758930ef4793999115229afd65de69e94c9cc/librt-0.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:760c25ed6ac968e24803eb5f7deb17ce026902d39865e83036bacbf5cf242aa8", size = 87540, upload-time = "2025-11-29T14:01:27.756Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/01/93799044a1cccac31f1074b07c583e181829d240539657e7f305ae63ae2a/librt-0.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4aa4a93a353ccff20df6e34fa855ae8fd788832c88f40a9070e3ddd3356a9f0e", size = 90597, upload-time = "2025-11-29T14:01:29.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/29/00c7f58b8f8eb1bad6529ffb6c9cdcc0890a27dac59ecda04f817ead5277/librt-0.6.3-cp314-cp314-win32.whl", hash = "sha256:cb92741c2b4ea63c09609b064b26f7f5d9032b61ae222558c55832ec3ad0bcaf", size = 18955, upload-time = "2025-11-29T14:01:30.325Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/13/2739e6e197a9f751375a37908a6a5b0bff637b81338497a1bcb5817394da/librt-0.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:fdcd095b1b812d756fa5452aca93b962cf620694c0cadb192cec2bb77dcca9a2", size = 20263, upload-time = "2025-11-29T14:01:31.287Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/73/393868fc2158705ea003114a24e73bb10b03bda31e9ad7b5c5ec6575338b/librt-0.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:822ca79e28720a76a935c228d37da6579edef048a17cd98d406a2484d10eda78", size = 19575, upload-time = "2025-11-29T14:01:32.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/6d/3c8ff3dec21bf804a205286dd63fd28dcdbe00b8dd7eb7ccf2e21a40a0b0/librt-0.6.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:078cd77064d1640cb7b0650871a772956066174d92c8aeda188a489b58495179", size = 28732, upload-time = "2025-11-29T14:01:33.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/90/e214b8b4aa34ed3d3f1040719c06c4d22472c40c5ef81a922d5af7876eb4/librt-0.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5cc22f7f5c0cc50ed69f4b15b9c51d602aabc4500b433aaa2ddd29e578f452f7", size = 29065, upload-time = "2025-11-29T14:01:34.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/90/ef61ed51f0a7770cc703422d907a757bbd8811ce820c333d3db2fd13542a/librt-0.6.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:14b345eb7afb61b9fdcdfda6738946bd11b8e0f6be258666b0646af3b9bb5916", size = 93703, upload-time = "2025-11-29T14:01:35.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/ae/c30bb119c35962cbe9a908a71da99c168056fc3f6e9bbcbc157d0b724d89/librt-0.6.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d46aa46aa29b067f0b8b84f448fd9719aaf5f4c621cc279164d76a9dc9ab3e8", size = 98890, upload-time = "2025-11-29T14:01:36.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/96/47a4a78d252d36f072b79d592df10600d379a895c3880c8cbd2ac699f0ad/librt-0.6.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1b51ba7d9d5d9001494769eca8c0988adce25d0a970c3ba3f2eb9df9d08036fc", size = 98255, upload-time = "2025-11-29T14:01:37.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/28/779b5cc3cd9987683884eb5f5672e3251676bebaaae6b7da1cf366eb1da1/librt-0.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ced0925a18fddcff289ef54386b2fc230c5af3c83b11558571124bfc485b8c07", size = 100769, upload-time = "2025-11-29T14:01:38.413Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/d7/771755e57c375cb9d25a4e106f570607fd856e2cb91b02418db1db954796/librt-0.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6bac97e51f66da2ca012adddbe9fd656b17f7368d439de30898f24b39512f40f", size = 98580, upload-time = "2025-11-29T14:01:39.459Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/ec/8b157eb8fbc066339a2f34b0aceb2028097d0ed6150a52e23284a311eafe/librt-0.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b2922a0e8fa97395553c304edc3bd36168d8eeec26b92478e292e5d4445c1ef0", size = 101706, upload-time = "2025-11-29T14:01:40.474Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/a8/4aaead9a06c795a318282aebf7d3e3e578fa889ff396e1b640c3be4c7806/librt-0.6.3-cp314-cp314t-win32.whl", hash = "sha256:f33462b19503ba68d80dac8a1354402675849259fb3ebf53b67de86421735a3a", size = 19465, upload-time = "2025-11-29T14:01:41.77Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/61/b7e6a02746c1731670c19ba07d86da90b1ae45d29e405c0b5615abf97cde/librt-0.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:04f8ce401d4f6380cfc42af0f4e67342bf34c820dae01343f58f472dbac75dcf", size = 21042, upload-time = "2025-11-29T14:01:42.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/3d/72cc9ec90bb80b5b1a65f0bb74a0f540195837baaf3b98c7fa4a7aa9718e/librt-0.6.3-cp314-cp314t-win_arm64.whl", hash = "sha256:afb39550205cc5e5c935762c6bf6a2bb34f7d21a68eadb25e2db7bf3593fecc0", size = 20246, upload-time = "2025-11-29T14:01:44.13Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy"
|
||||
version = "1.18.2"
|
||||
version = "1.19.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "librt" },
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "pathspec" },
|
||||
{ name = "tomli", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f9/b5/b58cdc25fadd424552804bf410855d52324183112aa004f0732c5f6324cf/mypy-1.19.0.tar.gz", hash = "sha256:f6b874ca77f733222641e5c46e4711648c4037ea13646fd0cdc814c2eaec2528", size = 3579025, upload-time = "2025-11-28T15:49:01.26Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/03/6f/657961a0743cff32e6c0611b63ff1c1970a0b482ace35b069203bf705187/mypy-1.18.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c1eab0cf6294dafe397c261a75f96dc2c31bffe3b944faa24db5def4e2b0f77c", size = 12807973, upload-time = "2025-09-19T00:10:35.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/e9/420822d4f661f13ca8900f5fa239b40ee3be8b62b32f3357df9a3045a08b/mypy-1.18.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7a780ca61fc239e4865968ebc5240bb3bf610ef59ac398de9a7421b54e4a207e", size = 11896527, upload-time = "2025-09-19T00:10:55.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/73/a05b2bbaa7005f4642fcfe40fb73f2b4fb6bb44229bd585b5878e9a87ef8/mypy-1.18.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:448acd386266989ef11662ce3c8011fd2a7b632e0ec7d61a98edd8e27472225b", size = 12507004, upload-time = "2025-09-19T00:11:05.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/01/f6e4b9f0d031c11ccbd6f17da26564f3a0f3c4155af344006434b0a05a9d/mypy-1.18.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9e171c465ad3901dc652643ee4bffa8e9fef4d7d0eece23b428908c77a76a66", size = 13245947, upload-time = "2025-09-19T00:10:46.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/97/19727e7499bfa1ae0773d06afd30ac66a58ed7437d940c70548634b24185/mypy-1.18.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:592ec214750bc00741af1f80cbf96b5013d81486b7bb24cb052382c19e40b428", size = 13499217, upload-time = "2025-09-19T00:09:39.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/4f/90dc8c15c1441bf31cf0f9918bb077e452618708199e530f4cbd5cede6ff/mypy-1.18.2-cp310-cp310-win_amd64.whl", hash = "sha256:7fb95f97199ea11769ebe3638c29b550b5221e997c63b14ef93d2e971606ebed", size = 9766753, upload-time = "2025-09-19T00:10:49.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/87/cafd3ae563f88f94eec33f35ff722d043e09832ea8530ef149ec1efbaf08/mypy-1.18.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:807d9315ab9d464125aa9fcf6d84fde6e1dc67da0b6f80e7405506b8ac72bc7f", size = 12731198, upload-time = "2025-09-19T00:09:44.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/e0/1e96c3d4266a06d4b0197ace5356d67d937d8358e2ee3ffac71faa843724/mypy-1.18.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:776bb00de1778caf4db739c6e83919c1d85a448f71979b6a0edd774ea8399341", size = 11817879, upload-time = "2025-09-19T00:09:47.131Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/ef/0c9ba89eb03453e76bdac5a78b08260a848c7bfc5d6603634774d9cd9525/mypy-1.18.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1379451880512ffce14505493bd9fe469e0697543717298242574882cf8cdb8d", size = 12427292, upload-time = "2025-09-19T00:10:22.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/52/ec4a061dd599eb8179d5411d99775bec2a20542505988f40fc2fee781068/mypy-1.18.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1331eb7fd110d60c24999893320967594ff84c38ac6d19e0a76c5fd809a84c86", size = 13163750, upload-time = "2025-09-19T00:09:51.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/5f/2cf2ceb3b36372d51568f2208c021870fe7834cf3186b653ac6446511839/mypy-1.18.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ca30b50a51e7ba93b00422e486cbb124f1c56a535e20eff7b2d6ab72b3b2e37", size = 13351827, upload-time = "2025-09-19T00:09:58.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/7d/2697b930179e7277529eaaec1513f8de622818696857f689e4a5432e5e27/mypy-1.18.2-cp311-cp311-win_amd64.whl", hash = "sha256:664dc726e67fa54e14536f6e1224bcfce1d9e5ac02426d2326e2bb4e081d1ce8", size = 9757983, upload-time = "2025-09-19T00:10:09.071Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/06/dfdd2bc60c66611dd8335f463818514733bc763e4760dee289dcc33df709/mypy-1.18.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33eca32dd124b29400c31d7cf784e795b050ace0e1f91b8dc035672725617e34", size = 12908273, upload-time = "2025-09-19T00:10:58.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/14/6a9de6d13a122d5608e1a04130724caf9170333ac5a924e10f670687d3eb/mypy-1.18.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a3c47adf30d65e89b2dcd2fa32f3aeb5e94ca970d2c15fcb25e297871c8e4764", size = 11920910, upload-time = "2025-09-19T00:10:20.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/a9/b29de53e42f18e8cc547e38daa9dfa132ffdc64f7250e353f5c8cdd44bee/mypy-1.18.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d6c838e831a062f5f29d11c9057c6009f60cb294fea33a98422688181fe2893", size = 12465585, upload-time = "2025-09-19T00:10:33.005Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/ae/6c3d2c7c61ff21f2bee938c917616c92ebf852f015fb55917fd6e2811db2/mypy-1.18.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01199871b6110a2ce984bde85acd481232d17413868c9807e95c1b0739a58914", size = 13348562, upload-time = "2025-09-19T00:10:11.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/31/aec68ab3b4aebdf8f36d191b0685d99faa899ab990753ca0fee60fb99511/mypy-1.18.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a2afc0fa0b0e91b4599ddfe0f91e2c26c2b5a5ab263737e998d6817874c5f7c8", size = 13533296, upload-time = "2025-09-19T00:10:06.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/83/abcb3ad9478fca3ebeb6a5358bb0b22c95ea42b43b7789c7fb1297ca44f4/mypy-1.18.2-cp312-cp312-win_amd64.whl", hash = "sha256:d8068d0afe682c7c4897c0f7ce84ea77f6de953262b12d07038f4d296d547074", size = 9828828, upload-time = "2025-09-19T00:10:28.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/04/7f462e6fbba87a72bc8097b93f6842499c428a6ff0c81dd46948d175afe8/mypy-1.18.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:07b8b0f580ca6d289e69209ec9d3911b4a26e5abfde32228a288eb79df129fcc", size = 12898728, upload-time = "2025-09-19T00:10:01.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/5b/61ed4efb64f1871b41fd0b82d29a64640f3516078f6c7905b68ab1ad8b13/mypy-1.18.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ed4482847168439651d3feee5833ccedbf6657e964572706a2adb1f7fa4dfe2e", size = 11910758, upload-time = "2025-09-19T00:10:42.607Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/46/d297d4b683cc89a6e4108c4250a6a6b717f5fa96e1a30a7944a6da44da35/mypy-1.18.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3ad2afadd1e9fea5cf99a45a822346971ede8685cc581ed9cd4d42eaf940986", size = 12475342, upload-time = "2025-09-19T00:11:00.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/83/45/4798f4d00df13eae3bfdf726c9244bcb495ab5bd588c0eed93a2f2dd67f3/mypy-1.18.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a431a6f1ef14cf8c144c6b14793a23ec4eae3db28277c358136e79d7d062f62d", size = 13338709, upload-time = "2025-09-19T00:11:03.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/09/479f7358d9625172521a87a9271ddd2441e1dab16a09708f056e97007207/mypy-1.18.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7ab28cc197f1dd77a67e1c6f35cd1f8e8b73ed2217e4fc005f9e6a504e46e7ba", size = 13529806, upload-time = "2025-09-19T00:10:26.073Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/cf/ac0f2c7e9d0ea3c75cd99dff7aec1c9df4a1376537cb90e4c882267ee7e9/mypy-1.18.2-cp313-cp313-win_amd64.whl", hash = "sha256:0e2785a84b34a72ba55fb5daf079a1003a34c05b22238da94fcae2bbe46f3544", size = 9833262, upload-time = "2025-09-19T00:10:40.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/8f/55fb488c2b7dabd76e3f30c10f7ab0f6190c1fcbc3e97b1e588ec625bbe2/mypy-1.19.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6148ede033982a8c5ca1143de34c71836a09f105068aaa8b7d5edab2b053e6c8", size = 13093239, upload-time = "2025-11-28T15:45:11.342Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/1b/278beea978456c56b3262266274f335c3ba5ff2c8108b3b31bec1ffa4c1d/mypy-1.19.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a9ac09e52bb0f7fb912f5d2a783345c72441a08ef56ce3e17c1752af36340a39", size = 12156128, upload-time = "2025-11-28T15:46:02.566Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f8/e06f951902e136ff74fd7a4dc4ef9d884faeb2f8eb9c49461235714f079f/mypy-1.19.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:11f7254c15ab3f8ed68f8e8f5cbe88757848df793e31c36aaa4d4f9783fd08ab", size = 12753508, upload-time = "2025-11-28T15:44:47.538Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/5a/d035c534ad86e09cee274d53cf0fd769c0b29ca6ed5b32e205be3c06878c/mypy-1.19.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:318ba74f75899b0e78b847d8c50821e4c9637c79d9a59680fc1259f29338cb3e", size = 13507553, upload-time = "2025-11-28T15:44:39.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/17/c4a5498e00071ef29e483a01558b285d086825b61cf1fb2629fbdd019d94/mypy-1.19.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cf7d84f497f78b682edd407f14a7b6e1a2212b433eedb054e2081380b7395aa3", size = 13792898, upload-time = "2025-11-28T15:44:31.102Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/f6/bb542422b3ee4399ae1cdc463300d2d91515ab834c6233f2fd1d52fa21e0/mypy-1.19.0-cp310-cp310-win_amd64.whl", hash = "sha256:c3385246593ac2b97f155a0e9639be906e73534630f663747c71908dfbf26134", size = 10048835, upload-time = "2025-11-28T15:48:15.744Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/d2/010fb171ae5ac4a01cc34fbacd7544531e5ace95c35ca166dd8fd1b901d0/mypy-1.19.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a31e4c28e8ddb042c84c5e977e28a21195d086aaffaf08b016b78e19c9ef8106", size = 13010563, upload-time = "2025-11-28T15:48:23.975Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/6b/63f095c9f1ce584fdeb595d663d49e0980c735a1d2004720ccec252c5d47/mypy-1.19.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:34ec1ac66d31644f194b7c163d7f8b8434f1b49719d403a5d26c87fff7e913f7", size = 12077037, upload-time = "2025-11-28T15:47:51.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/83/6cb93d289038d809023ec20eb0b48bbb1d80af40511fa077da78af6ff7c7/mypy-1.19.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cb64b0ba5980466a0f3f9990d1c582bcab8db12e29815ecb57f1408d99b4bff7", size = 12680255, upload-time = "2025-11-28T15:46:57.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/99/db/d217815705987d2cbace2edd9100926196d6f85bcb9b5af05058d6e3c8ad/mypy-1.19.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:120cffe120cca5c23c03c77f84abc0c14c5d2e03736f6c312480020082f1994b", size = 13421472, upload-time = "2025-11-28T15:47:59.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/51/d2beaca7c497944b07594f3f8aad8d2f0e8fc53677059848ae5d6f4d193e/mypy-1.19.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7a500ab5c444268a70565e374fc803972bfd1f09545b13418a5174e29883dab7", size = 13651823, upload-time = "2025-11-28T15:45:29.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/d1/7883dcf7644db3b69490f37b51029e0870aac4a7ad34d09ceae709a3df44/mypy-1.19.0-cp311-cp311-win_amd64.whl", hash = "sha256:c14a98bc63fd867530e8ec82f217dae29d0550c86e70debc9667fff1ec83284e", size = 10049077, upload-time = "2025-11-28T15:45:39.818Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/7e/1afa8fb188b876abeaa14460dc4983f909aaacaa4bf5718c00b2c7e0b3d5/mypy-1.19.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0fb3115cb8fa7c5f887c8a8d81ccdcb94cff334684980d847e5a62e926910e1d", size = 13207728, upload-time = "2025-11-28T15:46:26.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/13/f103d04962bcbefb1644f5ccb235998b32c337d6c13145ea390b9da47f3e/mypy-1.19.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3e19e3b897562276bb331074d64c076dbdd3e79213f36eed4e592272dabd760", size = 12202945, upload-time = "2025-11-28T15:48:49.143Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/93/a86a5608f74a22284a8ccea8592f6e270b61f95b8588951110ad797c2ddd/mypy-1.19.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9d491295825182fba01b6ffe2c6fe4e5a49dbf4e2bb4d1217b6ced3b4797bc6", size = 12718673, upload-time = "2025-11-28T15:47:37.193Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/58/cf08fff9ced0423b858f2a7495001fda28dc058136818ee9dffc31534ea9/mypy-1.19.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6016c52ab209919b46169651b362068f632efcd5eb8ef9d1735f6f86da7853b2", size = 13608336, upload-time = "2025-11-28T15:48:32.625Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/ed/9c509105c5a6d4b73bb08733102a3ea62c25bc02c51bca85e3134bf912d3/mypy-1.19.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f188dcf16483b3e59f9278c4ed939ec0254aa8a60e8fc100648d9ab5ee95a431", size = 13833174, upload-time = "2025-11-28T15:45:48.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/71/01939b66e35c6f8cb3e6fdf0b657f0fd24de2f8ba5e523625c8e72328208/mypy-1.19.0-cp312-cp312-win_amd64.whl", hash = "sha256:0e3c3d1e1d62e678c339e7ade72746a9e0325de42cd2cccc51616c7b2ed1a018", size = 10112208, upload-time = "2025-11-28T15:46:41.702Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/0d/a1357e6bb49e37ce26fcf7e3cc55679ce9f4ebee0cd8b6ee3a0e301a9210/mypy-1.19.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7686ed65dbabd24d20066f3115018d2dce030d8fa9db01aa9f0a59b6813e9f9e", size = 13191993, upload-time = "2025-11-28T15:47:22.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/75/8e5d492a879ec4490e6ba664b5154e48c46c85b5ac9785792a5ec6a4d58f/mypy-1.19.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fd4a985b2e32f23bead72e2fb4bbe5d6aceee176be471243bd831d5b2644672d", size = 12174411, upload-time = "2025-11-28T15:44:55.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/31/ad5dcee9bfe226e8eaba777e9d9d251c292650130f0450a280aec3485370/mypy-1.19.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fc51a5b864f73a3a182584b1ac75c404396a17eced54341629d8bdcb644a5bba", size = 12727751, upload-time = "2025-11-28T15:44:14.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/06/b6b8994ce07405f6039701f4b66e9d23f499d0b41c6dd46ec28f96d57ec3/mypy-1.19.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:37af5166f9475872034b56c5efdcf65ee25394e9e1d172907b84577120714364", size = 13593323, upload-time = "2025-11-28T15:46:34.699Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/b1/126e274484cccdf099a8e328d4fda1c7bdb98a5e888fa6010b00e1bbf330/mypy-1.19.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:510c014b722308c9bd377993bcbf9a07d7e0692e5fa8fc70e639c1eb19fc6bee", size = 13818032, upload-time = "2025-11-28T15:46:18.286Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/56/53a8f70f562dfc466c766469133a8a4909f6c0012d83993143f2a9d48d2d/mypy-1.19.0-cp313-cp313-win_amd64.whl", hash = "sha256:cabbee74f29aa9cd3b444ec2f1e4fa5a9d0d746ce7567a6a609e224429781f53", size = 10120644, upload-time = "2025-11-28T15:47:43.99Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/f4/7751f32f56916f7f8c229fe902cbdba3e4dd3f3ea9e8b872be97e7fc546d/mypy-1.19.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f2e36bed3c6d9b5f35d28b63ca4b727cb0228e480826ffc8953d1892ddc8999d", size = 13185236, upload-time = "2025-11-28T15:45:20.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/31/871a9531f09e78e8d145032355890384f8a5b38c95a2c7732d226b93242e/mypy-1.19.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a18d8abdda14035c5718acb748faec09571432811af129bf0d9e7b2d6699bf18", size = 12213902, upload-time = "2025-11-28T15:46:10.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/b8/af221910dd40eeefa2077a59107e611550167b9994693fc5926a0b0f87c0/mypy-1.19.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f75e60aca3723a23511948539b0d7ed514dda194bc3755eae0bfc7a6b4887aa7", size = 12738600, upload-time = "2025-11-28T15:44:22.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/9f/c39e89a3e319c1d9c734dedec1183b2cc3aefbab066ec611619002abb932/mypy-1.19.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8f44f2ae3c58421ee05fe609160343c25f70e3967f6e32792b5a78006a9d850f", size = 13592639, upload-time = "2025-11-28T15:48:08.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/6d/ffaf5f01f5e284d9033de1267e6c1b8f3783f2cf784465378a86122e884b/mypy-1.19.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:63ea6a00e4bd6822adbfc75b02ab3653a17c02c4347f5bb0cf1d5b9df3a05835", size = 13799132, upload-time = "2025-11-28T15:47:06.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/b0/c33921e73aaa0106224e5a34822411bea38046188eb781637f5a5b07e269/mypy-1.19.0-cp314-cp314-win_amd64.whl", hash = "sha256:3ad925b14a0bb99821ff6f734553294aa6a3440a8cb082fe1f5b84dfb662afb1", size = 10269832, upload-time = "2025-11-28T15:47:29.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/0e/fe228ed5aeab470c6f4eb82481837fadb642a5aa95cc8215fd2214822c10/mypy-1.19.0-py3-none-any.whl", hash = "sha256:0c01c99d626380752e527d5ce8e69ffbba2046eb8a060db0329690849cf9b6f9", size = 2469714, upload-time = "2025-11-28T15:45:33.22Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -359,7 +440,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic"
|
||||
version = "2.12.4"
|
||||
version = "2.12.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
@@ -367,9 +448,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/ad/a17bc283d7d81837c061c49e3eaa27a45991759a1b7eae1031921c6bd924/pydantic-2.12.4.tar.gz", hash = "sha256:0f8cb9555000a4b5b617f66bfd2566264c4984b27589d3b845685983e8ea85ac", size = 821038, upload-time = "2025-11-05T10:50:08.59Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/82/2f/e68750da9b04856e2a7ec56fc6f034a5a79775e9b9a81882252789873798/pydantic-2.12.4-py3-none-any.whl", hash = "sha256:92d3d202a745d46f9be6df459ac5a064fdaa3c1c4cd8adcfa332ccf3c05f871e", size = 463400, upload-time = "2025-11-05T10:50:06.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -557,28 +638,28 @@ sdist = { url = "https://files.pythonhosted.org/packages/36/47/ab65fc1d682befc31
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.14.5"
|
||||
version = "0.14.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/fa/fbb67a5780ae0f704876cb8ac92d6d76da41da4dc72b7ed3565ab18f2f52/ruff-0.14.5.tar.gz", hash = "sha256:8d3b48d7d8aad423d3137af7ab6c8b1e38e4de104800f0d596990f6ada1a9fc1", size = 5615944, upload-time = "2025-11-13T19:58:51.155Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/5b/dd7406afa6c95e3d8fa9d652b6d6dd17dd4a6bf63cb477014e8ccd3dcd46/ruff-0.14.7.tar.gz", hash = "sha256:3417deb75d23bd14a722b57b0a1435561db65f0ad97435b4cf9f85ffcef34ae5", size = 5727324, upload-time = "2025-11-28T20:55:10.525Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/31/c07e9c535248d10836a94e4f4e8c5a31a1beed6f169b31405b227872d4f4/ruff-0.14.5-py3-none-linux_armv6l.whl", hash = "sha256:f3b8248123b586de44a8018bcc9fefe31d23dda57a34e6f0e1e53bd51fd63594", size = 13171630, upload-time = "2025-11-13T19:57:54.894Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/5c/283c62516dca697cd604c2796d1487396b7a436b2f0ecc3fd412aca470e0/ruff-0.14.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:f7a75236570318c7a30edd7f5491945f0169de738d945ca8784500b517163a72", size = 13413925, upload-time = "2025-11-13T19:57:59.181Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/f3/aa319f4afc22cb6fcba2b9cdfc0f03bbf747e59ab7a8c5e90173857a1361/ruff-0.14.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d146132d1ee115f8802356a2dc9a634dbf58184c51bff21f313e8cd1c74899a", size = 12574040, upload-time = "2025-11-13T19:58:02.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/7f/cb5845fcc7c7e88ed57f58670189fc2ff517fe2134c3821e77e29fd3b0c8/ruff-0.14.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e2380596653dcd20b057794d55681571a257a42327da8894b93bbd6111aa801f", size = 13009755, upload-time = "2025-11-13T19:58:05.172Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/d2/bcbedbb6bcb9253085981730687ddc0cc7b2e18e8dc13cf4453de905d7a0/ruff-0.14.5-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2d1fa985a42b1f075a098fa1ab9d472b712bdb17ad87a8ec86e45e7fa6273e68", size = 12937641, upload-time = "2025-11-13T19:58:08.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/58/e25de28a572bdd60ffc6bb71fc7fd25a94ec6a076942e372437649cbb02a/ruff-0.14.5-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88f0770d42b7fa02bbefddde15d235ca3aa24e2f0137388cc15b2dcbb1f7c7a7", size = 13610854, upload-time = "2025-11-13T19:58:11.419Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/24/43bb3fd23ecee9861970978ea1a7a63e12a204d319248a7e8af539984280/ruff-0.14.5-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:3676cb02b9061fee7294661071c4709fa21419ea9176087cb77e64410926eb78", size = 15061088, upload-time = "2025-11-13T19:58:14.551Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/44/a022f288d61c2f8c8645b24c364b719aee293ffc7d633a2ca4d116b9c716/ruff-0.14.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b595bedf6bc9cab647c4a173a61acf4f1ac5f2b545203ba82f30fcb10b0318fb", size = 14734717, upload-time = "2025-11-13T19:58:17.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/81/5c6ba44de7e44c91f68073e0658109d8373b0590940efe5bd7753a2585a3/ruff-0.14.5-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f55382725ad0bdb2e8ee2babcbbfb16f124f5a59496a2f6a46f1d9d99d93e6e2", size = 14028812, upload-time = "2025-11-13T19:58:20.533Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/ef/41a8b60f8462cb320f68615b00299ebb12660097c952c600c762078420f8/ruff-0.14.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7497d19dce23976bdaca24345ae131a1d38dcfe1b0850ad8e9e6e4fa321a6e19", size = 13825656, upload-time = "2025-11-13T19:58:23.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/00/207e5de737fdb59b39eb1fac806904fe05681981b46d6a6db9468501062e/ruff-0.14.5-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:410e781f1122d6be4f446981dd479470af86537fb0b8857f27a6e872f65a38e4", size = 13959922, upload-time = "2025-11-13T19:58:26.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/7e/fa1f5c2776db4be405040293618846a2dece5c70b050874c2d1f10f24776/ruff-0.14.5-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c01be527ef4c91a6d55e53b337bfe2c0f82af024cc1a33c44792d6844e2331e1", size = 12932501, upload-time = "2025-11-13T19:58:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/d8/d86bf784d693a764b59479a6bbdc9515ae42c340a5dc5ab1dabef847bfaa/ruff-0.14.5-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f66e9bb762e68d66e48550b59c74314168ebb46199886c5c5aa0b0fbcc81b151", size = 12927319, upload-time = "2025-11-13T19:58:32.923Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/de/ee0b304d450ae007ce0cb3e455fe24fbcaaedae4ebaad6c23831c6663651/ruff-0.14.5-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d93be8f1fa01022337f1f8f3bcaa7ffee2d0b03f00922c45c2207954f351f465", size = 13206209, upload-time = "2025-11-13T19:58:35.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/aa/193ca7e3a92d74f17d9d5771a765965d2cf42c86e6f0fd95b13969115723/ruff-0.14.5-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c135d4b681f7401fe0e7312017e41aba9b3160861105726b76cfa14bc25aa367", size = 13953709, upload-time = "2025-11-13T19:58:39.002Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/f1/7119e42aa1d3bf036ffc9478885c2e248812b7de9abea4eae89163d2929d/ruff-0.14.5-py3-none-win32.whl", hash = "sha256:c83642e6fccfb6dea8b785eb9f456800dcd6a63f362238af5fc0c83d027dd08b", size = 12925808, upload-time = "2025-11-13T19:58:42.779Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/9d/7c0a255d21e0912114784e4a96bf62af0618e2190cae468cd82b13625ad2/ruff-0.14.5-py3-none-win_amd64.whl", hash = "sha256:9d55d7af7166f143c94eae1db3312f9ea8f95a4defef1979ed516dbb38c27621", size = 14331546, upload-time = "2025-11-13T19:58:45.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/80/69756670caedcf3b9be597a6e12276a6cf6197076eb62aad0c608f8efce0/ruff-0.14.5-py3-none-win_arm64.whl", hash = "sha256:4b700459d4649e2594b31f20a9de33bc7c19976d4746d8d0798ad959621d64a4", size = 13433331, upload-time = "2025-11-13T19:58:48.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/b1/7ea5647aaf90106f6d102230e5df874613da43d1089864da1553b899ba5e/ruff-0.14.7-py3-none-linux_armv6l.whl", hash = "sha256:b9d5cb5a176c7236892ad7224bc1e63902e4842c460a0b5210701b13e3de4fca", size = 13414475, upload-time = "2025-11-28T20:54:54.569Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/19/fddb4cd532299db9cdaf0efdc20f5c573ce9952a11cb532d3b859d6d9871/ruff-0.14.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:3f64fe375aefaf36ca7d7250292141e39b4cea8250427482ae779a2aa5d90015", size = 13634613, upload-time = "2025-11-28T20:55:17.54Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/2b/469a66e821d4f3de0440676ed3e04b8e2a1dc7575cf6fa3ba6d55e3c8557/ruff-0.14.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:93e83bd3a9e1a3bda64cb771c0d47cda0e0d148165013ae2d3554d718632d554", size = 12765458, upload-time = "2025-11-28T20:55:26.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/05/0b001f734fe550bcfde4ce845948ac620ff908ab7241a39a1b39bb3c5f49/ruff-0.14.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3838948e3facc59a6070795de2ae16e5786861850f78d5914a03f12659e88f94", size = 13236412, upload-time = "2025-11-28T20:55:28.602Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/36/8ed15d243f011b4e5da75cd56d6131c6766f55334d14ba31cce5461f28aa/ruff-0.14.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24c8487194d38b6d71cd0fd17a5b6715cda29f59baca1defe1e3a03240f851d1", size = 13182949, upload-time = "2025-11-28T20:55:33.265Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/cf/fcb0b5a195455729834f2a6eadfe2e4519d8ca08c74f6d2b564a4f18f553/ruff-0.14.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:79c73db6833f058a4be8ffe4a0913b6d4ad41f6324745179bd2aa09275b01d0b", size = 13816470, upload-time = "2025-11-28T20:55:08.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5d/34a4748577ff7a5ed2f2471456740f02e86d1568a18c9faccfc73bd9ca3f/ruff-0.14.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:12eb7014fccff10fc62d15c79d8a6be4d0c2d60fe3f8e4d169a0d2def75f5dad", size = 15289621, upload-time = "2025-11-28T20:55:30.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/53/0a9385f047a858ba133d96f3f8e3c9c66a31cc7c4b445368ef88ebeac209/ruff-0.14.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c623bbdc902de7ff715a93fa3bb377a4e42dd696937bf95669118773dbf0c50", size = 14975817, upload-time = "2025-11-28T20:55:24.107Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/d7/2f1c32af54c3b46e7fadbf8006d8b9bcfbea535c316b0bd8813d6fb25e5d/ruff-0.14.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f53accc02ed2d200fa621593cdb3c1ae06aa9b2c3cae70bc96f72f0000ae97a9", size = 14284549, upload-time = "2025-11-28T20:55:06.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/05/434ddd86becd64629c25fb6b4ce7637dd52a45cc4a4415a3008fe61c27b9/ruff-0.14.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:281f0e61a23fcdcffca210591f0f53aafaa15f9025b5b3f9706879aaa8683bc4", size = 14071389, upload-time = "2025-11-28T20:55:35.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/50/fdf89d4d80f7f9d4f420d26089a79b3bb1538fe44586b148451bc2ba8d9c/ruff-0.14.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:dbbaa5e14148965b91cb090236931182ee522a5fac9bc5575bafc5c07b9f9682", size = 14202679, upload-time = "2025-11-28T20:55:01.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/54/87b34988984555425ce967f08a36df0ebd339bb5d9d0e92a47e41151eafc/ruff-0.14.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1464b6e54880c0fe2f2d6eaefb6db15373331414eddf89d6b903767ae2458143", size = 13147677, upload-time = "2025-11-28T20:55:19.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/67/29/f55e4d44edfe053918a16a3299e758e1c18eef216b7a7092550d7a9ec51c/ruff-0.14.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f217ed871e4621ea6128460df57b19ce0580606c23aeab50f5de425d05226784", size = 13151392, upload-time = "2025-11-28T20:55:21.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/69/47aae6dbd4f1d9b4f7085f4d9dcc84e04561ee7ad067bf52e0f9b02e3209/ruff-0.14.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:6be02e849440ed3602d2eb478ff7ff07d53e3758f7948a2a598829660988619e", size = 13412230, upload-time = "2025-11-28T20:55:12.749Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/4b/6e96cb6ba297f2ba502a231cd732ed7c3de98b1a896671b932a5eefa3804/ruff-0.14.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:19a0f116ee5e2b468dfe80c41c84e2bbd6b74f7b719bee86c2ecde0a34563bcc", size = 14195397, upload-time = "2025-11-28T20:54:56.896Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/82/251d5f1aa4dcad30aed491b4657cecd9fb4274214da6960ffec144c260f7/ruff-0.14.7-py3-none-win32.whl", hash = "sha256:e33052c9199b347c8937937163b9b149ef6ab2e4bb37b042e593da2e6f6cccfa", size = 13126751, upload-time = "2025-11-28T20:55:03.47Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/b5/d0b7d145963136b564806f6584647af45ab98946660d399ec4da79cae036/ruff-0.14.7-py3-none-win_amd64.whl", hash = "sha256:e17a20ad0d3fad47a326d773a042b924d3ac31c6ca6deb6c72e9e6b5f661a7c6", size = 14531726, upload-time = "2025-11-28T20:54:59.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/d2/1637f4360ada6a368d3265bf39f2cf737a0aaab15ab520fc005903e883f8/ruff-0.14.7-py3-none-win_arm64.whl", hash = "sha256:be4d653d3bea1b19742fcc6502354e32f65cd61ff2fbdb365803ef2c2aec6228", size = 13609215, upload-time = "2025-11-28T20:55:15.375Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -590,6 +671,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.50.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ba/b8/73a0e6a6e079a9d9cfa64113d771e421640b6f679a52eeb9b32f72d871a1/starlette-0.50.0.tar.gz", hash = "sha256:a2a17b22203254bcbc2e1f926d2d55f3f9497f769416b3190768befe598fa3ca", size = 2646985, upload-time = "2025-11-01T15:25:27.516Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/52/1064f510b141bd54025f9b55105e26d1fa970b9be67ad766380a3c9b74b0/starlette-0.50.0-py3-none-any.whl", hash = "sha256:9e5391843ec9b6e472eed1365a78c8098cfceb7a74bfd4d6b1c0c0095efb3bca", size = 74033, upload-time = "2025-11-01T15:25:25.461Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tomli"
|
||||
version = "2.3.0"
|
||||
@@ -639,6 +733,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ty"
|
||||
version = "0.0.1a27"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8f/65/3592d7c73d80664378fc90d0a00c33449a99cbf13b984433c883815245f3/ty-0.0.1a27.tar.gz", hash = "sha256:d34fe04979f2c912700cbf0919e8f9b4eeaa10c4a2aff7450e5e4c90f998bc28", size = 4516059, upload-time = "2025-11-18T21:55:18.381Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/05/7945aa97356446fd53ed3ddc7ee02a88d8ad394217acd9428f472d6b109d/ty-0.0.1a27-py3-none-linux_armv6l.whl", hash = "sha256:3cbb735f5ecb3a7a5f5b82fb24da17912788c109086df4e97d454c8fb236fbc5", size = 9375047, upload-time = "2025-11-18T21:54:31.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/4e/89b167a03de0e9ec329dc89bc02e8694768e4576337ef6c0699987681342/ty-0.0.1a27-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4a6367236dc456ba2416563301d498aef8c6f8959be88777ef7ba5ac1bf15f0b", size = 9169540, upload-time = "2025-11-18T21:54:34.036Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/07/e62009ab9cc242e1becb2bd992097c80a133fce0d4f055fba6576150d08a/ty-0.0.1a27-py3-none-macosx_11_0_arm64.whl", hash = "sha256:8e93e231a1bcde964cdb062d2d5e549c24493fb1638eecae8fcc42b81e9463a4", size = 8711942, upload-time = "2025-11-18T21:54:36.3Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/43/f35716ec15406f13085db52e762a3cc663c651531a8124481d0ba602eca0/ty-0.0.1a27-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c5b6a8166b60117da1179851a3d719cc798bf7e61f91b35d76242f0059e9ae1d", size = 8984208, upload-time = "2025-11-18T21:54:39.453Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/79/486a3374809523172379768de882c7a369861165802990177fe81489b85f/ty-0.0.1a27-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfbe8b0e831c072b79a078d6c126d7f4d48ca17f64a103de1b93aeda32265dc5", size = 9157209, upload-time = "2025-11-18T21:54:42.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/08/9a7c8efcb327197d7d347c548850ef4b54de1c254981b65e8cd0672dc327/ty-0.0.1a27-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:90e09678331552e7c25d7eb47868b0910dc5b9b212ae22c8ce71a52d6576ddbb", size = 9519207, upload-time = "2025-11-18T21:54:45.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/9d/7b4680683e83204b9edec551bb91c21c789ebc586b949c5218157ee474b7/ty-0.0.1a27-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:88c03e4beeca79d85a5618921e44b3a6ea957e0453e08b1cdd418b51da645939", size = 10148794, upload-time = "2025-11-18T21:54:48.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/21/8b961b0ab00c28223f06b33222427a8e31aa04f39d1b236acc93021c626c/ty-0.0.1a27-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3ece5811322789fefe22fc088ed36c5879489cd39e913f9c1ff2a7678f089c61", size = 9900563, upload-time = "2025-11-18T21:54:51.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/eb/95e1f0b426c2ea8d443aa923fcab509059c467bbe64a15baaf573fea1203/ty-0.0.1a27-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f2ccb4f0fddcd6e2017c268dfce2489e9a36cb82a5900afe6425835248b1086", size = 9926355, upload-time = "2025-11-18T21:54:53.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/78/40e7f072049e63c414f2845df780be3a494d92198c87c2ffa65e63aecf3f/ty-0.0.1a27-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33450528312e41d003e96a1647780b2783ab7569bbc29c04fc76f2d1908061e3", size = 9480580, upload-time = "2025-11-18T21:54:56.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/da/f4a2dfedab39096808ddf7475f35ceb750d9a9da840bee4afd47b871742f/ty-0.0.1a27-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0a9ac635deaa2b15947701197ede40cdecd13f89f19351872d16f9ccd773fa1", size = 8957524, upload-time = "2025-11-18T21:54:59.085Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/ea/26fee9a20cf77a157316fd3ab9c6db8ad5a0b20b2d38a43f3452622587ac/ty-0.0.1a27-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:797fb2cd49b6b9b3ac9f2f0e401fb02d3aa155badc05a8591d048d38d28f1e0c", size = 9201098, upload-time = "2025-11-18T21:55:01.845Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/53/e14591d1275108c9ae28f97ac5d4b93adcc2c8a4b1b9a880dfa9d07c15f8/ty-0.0.1a27-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7fe81679a0941f85e98187d444604e24b15bde0a85874957c945751756314d03", size = 9275470, upload-time = "2025-11-18T21:55:04.23Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/44/e2c9acecac70bf06fb41de285e7be2433c2c9828f71e3bf0e886fc85c4fd/ty-0.0.1a27-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:355f651d0cdb85535a82bd9f0583f77b28e3fd7bba7b7da33dcee5a576eff28b", size = 9592394, upload-time = "2025-11-18T21:55:06.542Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/a7/4636369731b24ed07c2b4c7805b8d990283d677180662c532d82e4ef1a36/ty-0.0.1a27-py3-none-win32.whl", hash = "sha256:61782e5f40e6df622093847b34c366634b75d53f839986f1bf4481672ad6cb55", size = 8783816, upload-time = "2025-11-18T21:55:09.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/1d/b76487725628d9e81d9047dc0033a5e167e0d10f27893d04de67fe1a9763/ty-0.0.1a27-py3-none-win_amd64.whl", hash = "sha256:c682b238085d3191acddcf66ef22641562946b1bba2a7f316012d5b2a2f4de11", size = 9616833, upload-time = "2025-11-18T21:55:12.457Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/db/c7cd5276c8f336a3cf87992b75ba9d486a7cf54e753fcd42495b3bc56fb7/ty-0.0.1a27-py3-none-win_arm64.whl", hash = "sha256:e146dfa32cbb0ac6afb0cb65659e87e4e313715e68d76fe5ae0a4b3d5b912ce8", size = 9137796, upload-time = "2025-11-18T21:55:15.897Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
|
||||
Reference in New Issue
Block a user