[BREAKING] Python: Checkpoint refactor: encode/decode, checkpoint format, etc (#3744)

* WIP: Checkpoint refactor: encode/decode, checkpoint format, etc

* WIP: Remove workflow ID in checkpoints

* Refactor checkpointing

* Add get_latest tests

* Increase test coverage

* Fix formatting

* Fix unit tests

* Fix samples

* fix unit tests

* fix pipeline

* Copilot comments

* Fix tests

* Fix more tests

* Address comments part 1

* Address comments part 2

* Comments
This commit is contained in:
Tao Chen
2026-02-11 12:57:15 -08:00
committed by GitHub
Unverified
parent a2a672b687
commit 7db6c4ab4e
43 changed files with 3335 additions and 2075 deletions
+5 -3
View File
@@ -24,8 +24,8 @@
],
"words": [
"aeiou",
"aiplatform",
"agui",
"aiplatform",
"azuredocindex",
"azuredocs",
"azurefunctions",
@@ -57,20 +57,22 @@
"nopep",
"NOSQL",
"ollama",
"otlp",
"Onnx",
"onyourdatatest",
"OPENAI",
"opentelemetry",
"OTEL",
"otlp",
"powerfx",
"protos",
"pydantic",
"pytestmark",
"qdrant",
"retrywrites",
"streamable",
"serde",
"streamable",
"superstep",
"supersteps",
"templating",
"uninstrument",
"vectordb",
@@ -13,7 +13,6 @@ from ._checkpoint import (
InMemoryCheckpointStorage,
WorkflowCheckpoint,
)
from ._checkpoint_summary import WorkflowCheckpointSummary, get_checkpoint_summary
from ._const import (
DEFAULT_MAX_ITERATIONS,
)
@@ -107,7 +106,6 @@ __all__ = [
"WorkflowBuilder",
"WorkflowCheckpoint",
"WorkflowCheckpointException",
"WorkflowCheckpointSummary",
"WorkflowContext",
"WorkflowConvergenceException",
"WorkflowErrorDetails",
@@ -124,7 +122,6 @@ __all__ = [
"WorkflowViz",
"create_edge_runner",
"executor",
"get_checkpoint_summary",
"handler",
"resolve_agent_id",
"response_handler",
@@ -13,9 +13,7 @@ from .._agents import SupportsAgentRun
from .._threads import AgentThread
from .._types import AgentResponse, AgentResponseUpdate, Message
from ._agent_utils import resolve_agent_id
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._const import WORKFLOW_RUN_KWARGS_KEY
from ._conversation_state import encode_chat_messages
from ._executor import Executor, handler
from ._message_utils import normalize_messages_input
from ._request_info_mixin import response_handler
@@ -232,11 +230,11 @@ class AgentExecutor(Executor):
serialized_thread = await self._agent_thread.serialize()
return {
"cache": encode_chat_messages(self._cache),
"full_conversation": encode_chat_messages(self._full_conversation),
"cache": self._cache,
"full_conversation": self._full_conversation,
"agent_thread": serialized_thread,
"pending_agent_requests": encode_checkpoint_value(self._pending_agent_requests),
"pending_responses_to_agent": encode_checkpoint_value(self._pending_responses_to_agent),
"pending_agent_requests": self._pending_agent_requests,
"pending_responses_to_agent": self._pending_responses_to_agent,
}
@override
@@ -246,27 +244,11 @@ class AgentExecutor(Executor):
Args:
state: Checkpoint data dict
"""
from ._conversation_state import decode_chat_messages
cache_payload = state.get("cache")
if cache_payload:
try:
self._cache = decode_chat_messages(cache_payload)
except Exception as exc:
logger.warning("Failed to restore cache: %s", exc)
self._cache = []
else:
self._cache = []
self._cache = cache_payload or []
full_conversation_payload = state.get("full_conversation")
if full_conversation_payload:
try:
self._full_conversation = decode_chat_messages(full_conversation_payload)
except Exception as exc:
logger.warning("Failed to restore full conversation: %s", exc)
self._full_conversation = []
else:
self._full_conversation = []
self._full_conversation = full_conversation_payload or []
thread_payload = state.get("agent_thread")
if thread_payload:
@@ -282,11 +264,11 @@ class AgentExecutor(Executor):
pending_requests_payload = state.get("pending_agent_requests")
if pending_requests_payload:
self._pending_agent_requests = decode_checkpoint_value(pending_requests_payload)
self._pending_agent_requests = pending_requests_payload
pending_responses_payload = state.get("pending_responses_to_agent")
if pending_responses_payload:
self._pending_responses_to_agent = decode_checkpoint_value(pending_responses_payload)
self._pending_responses_to_agent = pending_responses_payload
def reset(self) -> None:
"""Reset the internal cache of the executor."""
@@ -3,18 +3,29 @@
from __future__ import annotations
import asyncio
import copy
import json
import logging
import os
import uuid
from collections.abc import Mapping
from dataclasses import asdict, dataclass, field
from dataclasses import dataclass, field, fields
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Protocol
from typing import TYPE_CHECKING, Any, Protocol, TypeAlias
from ._exceptions import WorkflowCheckpointException
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ._events import WorkflowEvent
from ._runner_context import WorkflowMessage
# Type alias for checkpoint IDs in case we want to change the
# underlying type in the future (e.g., to UUID or a custom class)
CheckpointID: TypeAlias = str
@dataclass(slots=True)
class WorkflowCheckpoint:
@@ -23,15 +34,31 @@ class WorkflowCheckpoint:
Checkpoints capture the full execution state of a workflow at a specific point,
enabling workflows to be paused and resumed.
Note that a checkpoint is not tied to a specific workflow instance, but rather to
a workflow definition (identified by workflow_name and graph_signature_hash). Thus,
the ID of the workflow instance that created the checkpoint is not included in the
checkpoint data. This allows checkpoints to be shared and restored across different
workflow instances of the same workflow definition.
Attributes:
workflow_name: Name of the workflow this checkpoint belongs to. This acts as a
logical grouping for checkpoints and can be used to filter checkpoints by
workflow. Workflows with the same name are expected to have compatible graph
structures for checkpointing.
graph_signature_hash: Hash of the workflow graph topology to validate checkpoint
compatibility during restore
checkpoint_id: Unique identifier for this checkpoint
workflow_id: Identifier of the workflow this checkpoint belongs to
previous_checkpoint_id: ID of the previous checkpoint in the chain, if any. This
allows chaining checkpoints together to form a history of workflow states.
timestamp: ISO 8601 timestamp when checkpoint was created
messages: Messages exchanged between executors
state: Committed workflow state including user data and executor states.
This contains only committed state; pending state changes are not
included in checkpoints. Executor states are stored under the
reserved key '_executor_state'.
This contains only committed state; pending state changes are not
included in checkpoints. Executor states are stored under the
reserved key '_executor_state'.
pending_request_info_events: Any pending request info events that have not
yet been processed at the time of checkpointing. This allows the workflow
to resume with the correct pending events after a restore.
iteration_count: Current iteration number when checkpoint was created
metadata: Additional metadata (e.g., superstep info, graph signature)
version: Checkpoint format version
@@ -41,14 +68,17 @@ class WorkflowCheckpoint:
See State class documentation for details on reserved keys.
"""
checkpoint_id: str = field(default_factory=lambda: str(uuid.uuid4()))
workflow_id: str = ""
workflow_name: str
graph_signature_hash: str
checkpoint_id: CheckpointID = field(default_factory=lambda: str(uuid.uuid4()))
previous_checkpoint_id: CheckpointID | None = None
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
# Core workflow state
messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # type: ignore[misc]
messages: dict[str, list[WorkflowMessage]] = field(default_factory=dict) # type: ignore[misc]
state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc]
pending_request_info_events: dict[str, dict[str, Any]] = field(default_factory=dict) # type: ignore[misc]
pending_request_info_events: dict[str, WorkflowEvent[Any]] = field(default_factory=dict) # type: ignore[misc]
# Runtime state
iteration_count: int = 0
@@ -58,34 +88,104 @@ class WorkflowCheckpoint:
version: str = "1.0"
def to_dict(self) -> dict[str, Any]:
return asdict(self)
"""Convert the WorkflowCheckpoint to a dictionary.
Notes:
1. This method does not recursively convert nested dataclasses to dicts.
2. This is a shallow conversion. The resulting dict will contain the same
references to nested objects as the original dataclass.
"""
return {f.name: getattr(self, f.name) for f in fields(self)}
@classmethod
def from_dict(cls, data: Mapping[str, Any]) -> WorkflowCheckpoint:
return cls(**data)
"""Create a WorkflowCheckpoint from a dictionary.
Args:
data: Dictionary containing checkpoint fields.
Returns:
A new WorkflowCheckpoint instance.
Raises:
WorkflowCheckpointException: If required fields are missing.
"""
try:
return cls(**data)
except Exception as ex:
raise WorkflowCheckpointException(f"Failed to create WorkflowCheckpoint from dict: {ex}") from ex
class CheckpointStorage(Protocol):
"""Protocol for checkpoint storage backends."""
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
"""Save a checkpoint and return its ID."""
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
"""Save a checkpoint and return its ID.
Args:
checkpoint: The WorkflowCheckpoint object to save.
Returns:
The unique ID of the saved checkpoint.
"""
...
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
"""Load a checkpoint by ID."""
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
"""Load a checkpoint by ID.
Args:
checkpoint_id: The unique ID of the checkpoint to load.
Returns:
The WorkflowCheckpoint object corresponding to the given ID.
Raises:
WorkflowCheckpointException: If no checkpoint with the given ID exists.
"""
...
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
"""List checkpoint objects for a given workflow name.
Args:
workflow_name: The name of the workflow to list checkpoints for.
Returns:
A list of WorkflowCheckpoint objects for the specified workflow name.
"""
...
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
async def delete(self, checkpoint_id: CheckpointID) -> bool:
"""Delete a checkpoint by ID.
Args:
checkpoint_id: The unique ID of the checkpoint to delete.
Returns:
True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists.
"""
...
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
"""Delete a checkpoint by ID."""
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
"""Get the latest checkpoint for a given workflow name.
Args:
workflow_name: The name of the workflow to get the latest checkpoint for.
Returns:
The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist.
"""
...
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
"""List checkpoint IDs for a given workflow name.
Args:
workflow_name: The name of the workflow to list checkpoint IDs for.
Returns:
A list of checkpoint IDs for the specified workflow name.
"""
...
@@ -94,34 +194,27 @@ class InMemoryCheckpointStorage:
def __init__(self) -> None:
"""Initialize the memory storage."""
self._checkpoints: dict[str, WorkflowCheckpoint] = {}
self._checkpoints: dict[CheckpointID, WorkflowCheckpoint] = {}
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
"""Save a checkpoint and return its ID."""
self._checkpoints[checkpoint.checkpoint_id] = checkpoint
self._checkpoints[checkpoint.checkpoint_id] = copy.deepcopy(checkpoint)
logger.debug(f"Saved checkpoint {checkpoint.checkpoint_id} to memory")
return checkpoint.checkpoint_id
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
"""Load a checkpoint by ID."""
checkpoint = self._checkpoints.get(checkpoint_id)
if checkpoint:
logger.debug(f"Loaded checkpoint {checkpoint_id} from memory")
return checkpoint
return checkpoint
raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
if workflow_id is None:
return list(self._checkpoints.keys())
return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
"""List checkpoint objects for a given workflow name."""
return [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
if workflow_id is None:
return list(self._checkpoints.values())
return [cp for cp in self._checkpoints.values() if cp.workflow_id == workflow_id]
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
async def delete(self, checkpoint_id: CheckpointID) -> bool:
"""Delete a checkpoint by ID."""
if checkpoint_id in self._checkpoints:
del self._checkpoints[checkpoint_id]
@@ -129,9 +222,31 @@ class InMemoryCheckpointStorage:
return True
return False
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
"""Get the latest checkpoint for a given workflow name."""
checkpoints = [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
if not checkpoints:
return None
latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}")
return latest_checkpoint
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_name == workflow_name]
class FileCheckpointStorage:
"""File-based checkpoint storage for persistence."""
"""File-based checkpoint storage for persistence.
This storage implements a hybrid approach where the checkpoint metadata and structure are
stored in JSON format, while the actual state data (which may contain complex Python objects)
is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows
for human-readable checkpoint files while preserving the ability to store complex Python objects.
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
"""
def __init__(self, storage_path: str | Path):
"""Initialize the file storage."""
@@ -139,15 +254,45 @@ class FileCheckpointStorage:
self.storage_path.mkdir(parents=True, exist_ok=True)
logger.info(f"Initialized file checkpoint storage at {self.storage_path}")
async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str:
"""Save a checkpoint and return its ID."""
file_path = self.storage_path / f"{checkpoint.checkpoint_id}.json"
checkpoint_dict = asdict(checkpoint)
def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path:
"""Validate that a checkpoint ID resolves to a path within the storage directory.
This can prevent someone from crafting a checkpoint ID that points to an arbitrary
file on the filesystem.
Args:
checkpoint_id: The checkpoint ID to validate.
Returns:
The validated file path.
Raises:
WorkflowCheckpointException: If the checkpoint ID would resolve outside the storage directory.
"""
file_path = (self.storage_path / f"{checkpoint_id}.json").resolve()
if not file_path.is_relative_to(self.storage_path.resolve()):
raise WorkflowCheckpointException(f"Invalid checkpoint ID: {checkpoint_id}")
return file_path
async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID:
"""Save a checkpoint and return its ID.
Args:
checkpoint: The WorkflowCheckpoint object to save.
Returns:
The unique ID of the saved checkpoint.
"""
from ._checkpoint_encoding import encode_checkpoint_value
file_path = self._validate_file_path(checkpoint.checkpoint_id)
checkpoint_dict = checkpoint.to_dict()
encoded_checkpoint = encode_checkpoint_value(checkpoint_dict)
def _write_atomic() -> None:
tmp_path = file_path.with_suffix(".json.tmp")
with open(tmp_path, "w") as f:
json.dump(checkpoint_dict, f, indent=2, ensure_ascii=False)
json.dump(encoded_checkpoint, f, indent=2, ensure_ascii=False)
os.replace(tmp_path, file_path)
await asyncio.to_thread(_write_atomic)
@@ -155,60 +300,78 @@ class FileCheckpointStorage:
logger.info(f"Saved checkpoint {checkpoint.checkpoint_id} to {file_path}")
return checkpoint.checkpoint_id
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
"""Load a checkpoint by ID."""
file_path = self.storage_path / f"{checkpoint_id}.json"
async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
"""Load a checkpoint by ID.
Args:
checkpoint_id: The unique ID of the checkpoint to load.
Returns:
The WorkflowCheckpoint object corresponding to the given ID.
Raises:
WorkflowCheckpointException: If no checkpoint with the given ID exists,
or if checkpoint decoding fails.
"""
file_path = self._validate_file_path(checkpoint_id)
if not file_path.exists():
return None
raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}")
def _read() -> dict[str, Any]:
with open(file_path) as f:
return json.load(f) # type: ignore[no-any-return]
checkpoint_dict = await asyncio.to_thread(_read)
encoded_checkpoint = await asyncio.to_thread(_read)
checkpoint = WorkflowCheckpoint(**checkpoint_dict)
from ._checkpoint_encoding import CheckpointDecodingError, decode_checkpoint_value
try:
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
except CheckpointDecodingError as exc:
raise WorkflowCheckpointException(f"Failed to decode checkpoint {checkpoint_id}: {exc}") from exc
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
logger.info(f"Loaded checkpoint {checkpoint_id} from {file_path}")
return checkpoint
async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]:
"""List checkpoint IDs. If workflow_id is provided, filter by that workflow."""
async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]:
"""List checkpoint objects for a given workflow name.
def _list_ids() -> list[str]:
checkpoint_ids: list[str] = []
for file_path in self.storage_path.glob("*.json"):
try:
with open(file_path) as f:
data = json.load(f)
if workflow_id is None or data.get("workflow_id") == workflow_id:
checkpoint_ids.append(data.get("checkpoint_id", file_path.stem))
except Exception as e:
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
return checkpoint_ids
Args:
workflow_name: The name of the workflow to list checkpoints for.
return await asyncio.to_thread(_list_ids)
async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]:
"""List checkpoint objects. If workflow_id is provided, filter by that workflow."""
Returns:
A list of WorkflowCheckpoint objects for the specified workflow name.
"""
def _list_checkpoints() -> list[WorkflowCheckpoint]:
checkpoints: list[WorkflowCheckpoint] = []
for file_path in self.storage_path.glob("*.json"):
try:
with open(file_path) as f:
data = json.load(f)
if workflow_id is None or data.get("workflow_id") == workflow_id:
checkpoints.append(WorkflowCheckpoint.from_dict(data))
encoded_checkpoint = json.load(f)
from ._checkpoint_encoding import decode_checkpoint_value
decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint)
checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict)
if checkpoint.workflow_name == workflow_name:
checkpoints.append(checkpoint)
except Exception as e:
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
return checkpoints
return await asyncio.to_thread(_list_checkpoints)
async def delete_checkpoint(self, checkpoint_id: str) -> bool:
"""Delete a checkpoint by ID."""
file_path = self.storage_path / f"{checkpoint_id}.json"
async def delete(self, checkpoint_id: CheckpointID) -> bool:
"""Delete a checkpoint by ID.
Args:
checkpoint_id: The unique ID of the checkpoint to delete.
Returns:
True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists.
"""
file_path = self._validate_file_path(checkpoint_id)
def _delete() -> bool:
if file_path.exists():
@@ -218,3 +381,43 @@ class FileCheckpointStorage:
return False
return await asyncio.to_thread(_delete)
async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None:
"""Get the latest checkpoint for a given workflow name.
Args:
workflow_name: The name of the workflow to get the latest checkpoint for.
Returns:
The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist.
"""
checkpoints = await self.list_checkpoints(workflow_name=workflow_name)
if not checkpoints:
return None
latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}")
return latest_checkpoint
async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]:
"""List checkpoint IDs for a given workflow name.
Args:
workflow_name: The name of the workflow to list checkpoint IDs for.
Returns:
A list of checkpoint IDs for the specified workflow name.
"""
def _list_ids() -> list[CheckpointID]:
checkpoint_ids: list[CheckpointID] = []
for file_path in self.storage_path.glob("*.json"):
try:
with open(file_path) as f:
data = json.load(f)
if data.get("workflow_name") == workflow_name:
checkpoint_ids.append(data.get("checkpoint_id", file_path.stem))
except Exception as e:
logger.warning(f"Failed to read checkpoint file {file_path}: {e}")
return checkpoint_ids
return await asyncio.to_thread(_list_ids)
@@ -2,269 +2,169 @@
from __future__ import annotations
import contextlib
import importlib
import logging
import sys
from dataclasses import fields, is_dataclass
from typing import Any, cast
import base64
import pickle # nosec # noqa: S403
from typing import Any
# Checkpoint serialization helpers
MODEL_MARKER = "__af_model__"
DATACLASS_MARKER = "__af_dataclass__"
from agent_framework import get_logger
# Guards to prevent runaway recursion while encoding arbitrary user data
_MAX_ENCODE_DEPTH = 100
_CYCLE_SENTINEL = "<cycle>"
"""Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data.
logger = logging.getLogger(__name__)
This hybrid approach provides:
- Human-readable JSON structure for debugging and inspection of primitives and collections
- Full Python object fidelity via pickle for data values (non-JSON-native types)
- Base64 encoding to embed binary pickle data in JSON strings
SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints
from trusted sources. Loading a malicious checkpoint file can execute arbitrary code.
"""
logger = get_logger(__name__)
# Marker to identify pickled values in serialized JSON
_PICKLE_MARKER = "__pickled__"
_TYPE_MARKER = "__type__"
# Types that are natively JSON-serializable and don't need pickling
_JSON_NATIVE_TYPES = (str, int, float, bool, type(None))
class CheckpointDecodingError(Exception):
"""Raised when checkpoint decoding fails due to type mismatch or corruption."""
def encode_checkpoint_value(value: Any) -> Any:
"""Recursively encode values into JSON-serializable structures.
"""Encode a Python value for checkpoint storage.
- Objects exposing to_dict/to_json -> { MODEL_MARKER: "module:Class", value: encoded }
- dataclass instances -> { DATACLASS_MARKER: "module:Class", value: {field: encoded} }
- dict -> encode keys as str and values recursively
- list/tuple/set -> list of encoded items
- other -> returned as-is if already JSON-serializable
JSON-native types (str, int, float, bool, None) pass through unchanged.
Collections (dict, list) are recursed with their values encoded.
All other types (dataclasses, custom objects, datetime, etc.) are pickled
and stored as base64-encoded strings.
Includes cycle and depth protection to avoid infinite recursion.
Args:
value: Any Python value to encode.
Returns:
A JSON-serializable representation of the value.
"""
def _enc(v: Any, stack: set[int], depth: int) -> Any:
# Depth guard
if depth > _MAX_ENCODE_DEPTH:
logger.debug(f"Max encode depth reached at depth={depth} for type={type(v)}")
return "<max_depth>"
# Structured model handling (objects exposing to_dict/to_json)
if _supports_model_protocol(v):
cls = cast(type[Any], type(v)) # type: ignore
try:
if hasattr(v, "to_dict") and callable(getattr(v, "to_dict", None)):
raw = v.to_dict() # type: ignore[attr-defined]
strategy = "to_dict"
elif hasattr(v, "to_json") and callable(getattr(v, "to_json", None)):
serialized = v.to_json() # type: ignore[attr-defined]
if isinstance(serialized, (bytes, bytearray)):
try:
serialized = serialized.decode()
except Exception:
serialized = serialized.decode(errors="replace")
raw = serialized
strategy = "to_json"
else:
raise AttributeError("Structured model lacks serialization hooks")
return {
MODEL_MARKER: f"{cls.__module__}:{cls.__name__}",
"strategy": strategy,
"value": _enc(raw, stack, depth + 1),
}
except Exception as exc: # best-effort fallback
logger.debug(f"Structured model serialization failed for {cls}: {exc}")
return str(v)
# Dataclasses (instances only)
if is_dataclass(v) and not isinstance(v, type):
oid = id(v)
if oid in stack:
logger.debug("Cycle detected while encoding dataclass instance")
return _CYCLE_SENTINEL
stack.add(oid)
try:
# type(v) already narrows sufficiently; cast was redundant
dc_cls: type[Any] = type(v)
field_values: dict[str, Any] = {}
for f in fields(v):
field_values[f.name] = _enc(getattr(v, f.name), stack, depth + 1)
return {
DATACLASS_MARKER: f"{dc_cls.__module__}:{dc_cls.__name__}",
"value": field_values,
}
finally:
stack.remove(oid)
# Collections
if isinstance(v, dict):
v_dict = cast("dict[object, object]", v)
oid = id(v_dict)
if oid in stack:
logger.debug("Cycle detected while encoding dict")
return _CYCLE_SENTINEL
stack.add(oid)
try:
json_dict: dict[str, Any] = {}
for k_any, val_any in v_dict.items(): # type: ignore[assignment]
k_str: str = str(k_any)
json_dict[k_str] = _enc(val_any, stack, depth + 1)
return json_dict
finally:
stack.remove(oid)
if isinstance(v, (list, tuple, set)):
iterable_v = cast("list[object] | tuple[object, ...] | set[object]", v)
oid = id(iterable_v)
if oid in stack:
logger.debug("Cycle detected while encoding iterable")
return _CYCLE_SENTINEL
stack.add(oid)
try:
seq: list[object] = list(iterable_v)
encoded_list: list[Any] = []
for item in seq:
encoded_list.append(_enc(item, stack, depth + 1))
return encoded_list
finally:
stack.remove(oid)
# Primitives (or unknown objects): ensure JSON-serializable
if isinstance(v, (str, int, float, bool)) or v is None:
return v
# Fallback: stringify unknown objects to avoid JSON serialization errors
try:
return str(v)
except Exception:
return f"<{type(v).__name__}>"
return _enc(value, set(), 0)
return _encode(value)
def decode_checkpoint_value(value: Any) -> Any:
"""Recursively decode values previously encoded by encode_checkpoint_value."""
"""Decode a value from checkpoint storage.
Reverses the encoding performed by encode_checkpoint_value.
Pickled values (identified by _PICKLE_MARKER) are decoded and unpickled.
WARNING: Only call this with trusted data. Pickle can execute
arbitrary code during deserialization. The post-unpickle type verification
detects accidental corruption or type mismatches, but cannot prevent
arbitrary code execution from malicious pickle payloads.
Args:
value: A JSON-deserialized value from checkpoint storage.
Returns:
The original Python value.
Raises:
CheckpointDecodingError: If the unpickled object's type doesn't match
the recorded type, indicating corruption, or if the base64/pickle
data is malformed.
"""
return _decode(value)
def _encode(value: Any) -> Any:
"""Recursively encode a value for JSON storage."""
# JSON-native types pass through
if isinstance(value, _JSON_NATIVE_TYPES):
return value
# Recursively encode dict values (keys become strings)
if isinstance(value, dict):
value_dict = cast(dict[str, Any], value) # encoded form always uses string keys
# Structured model marker handling
if MODEL_MARKER in value_dict and "value" in value_dict:
type_key: str | None = value_dict.get(MODEL_MARKER) # type: ignore[assignment]
strategy: str | None = value_dict.get("strategy") # type: ignore[assignment]
raw_encoded: Any = value_dict.get("value")
decoded_payload = decode_checkpoint_value(raw_encoded)
if isinstance(type_key, str):
try:
cls = _import_qualified_name(type_key)
except Exception as exc:
logger.debug(f"Failed to import structured model {type_key}: {exc}")
cls = None
return {str(k): _encode(v) for k, v in value.items()} # type: ignore
if cls is not None:
# Verify the class actually supports the model protocol
if not _class_supports_model_protocol(cls):
logger.debug(f"Class {type_key} does not support model protocol; returning raw value")
return decoded_payload
if strategy == "to_dict" and hasattr(cls, "from_dict"):
with contextlib.suppress(Exception):
return cls.from_dict(decoded_payload)
if strategy == "to_json" and hasattr(cls, "from_json"):
if isinstance(decoded_payload, (str, bytes, bytearray)):
with contextlib.suppress(Exception):
return cls.from_json(decoded_payload)
if isinstance(decoded_payload, dict) and hasattr(cls, "from_dict"):
with contextlib.suppress(Exception):
return cls.from_dict(decoded_payload)
return decoded_payload
# Dataclass marker handling
if DATACLASS_MARKER in value_dict and "value" in value_dict:
type_key_dc: str | None = value_dict.get(DATACLASS_MARKER) # type: ignore[assignment]
raw_dc: Any = value_dict.get("value")
decoded_raw = decode_checkpoint_value(raw_dc)
if isinstance(type_key_dc, str):
try:
module_name, class_name = type_key_dc.split(":", 1)
module = sys.modules.get(module_name)
if module is None:
module = importlib.import_module(module_name)
cls_dc: Any = getattr(module, class_name)
# Verify the class is actually a dataclass type (not an instance)
if not isinstance(cls_dc, type) or not is_dataclass(cls_dc):
logger.debug(f"Class {type_key_dc} is not a dataclass type; returning raw value")
return decoded_raw
constructed = _instantiate_checkpoint_dataclass(cls_dc, decoded_raw)
if constructed is not None:
return constructed
except Exception as exc:
logger.debug(f"Failed to decode dataclass {type_key_dc}: {exc}; returning raw value")
return decoded_raw
# Regular dict: decode recursively
decoded: dict[str, Any] = {}
for k_any, v_any in value_dict.items():
decoded[k_any] = decode_checkpoint_value(v_any)
return decoded
# Recursively encode list items (lists are JSON-native collections)
if isinstance(value, list):
# After isinstance check, treat value as list[Any] for decoding
value_list: list[Any] = value # type: ignore[assignment]
return [decode_checkpoint_value(v_any) for v_any in value_list]
return [_encode(item) for item in value] # type: ignore
# Everything else (tuples, sets, dataclasses, custom objects, etc.): pickle and base64 encode
return {
_PICKLE_MARKER: _pickle_to_base64(value),
_TYPE_MARKER: _type_to_key(type(value)), # type: ignore
}
def _decode(value: Any) -> Any:
"""Recursively decode a value from JSON storage."""
# JSON-native types pass through
if isinstance(value, _JSON_NATIVE_TYPES):
return value
# Handle encoded dicts
if isinstance(value, dict):
# Pickled value: decode, unpickle, and verify type
if _PICKLE_MARKER in value and _TYPE_MARKER in value:
obj = _base64_to_unpickle(value[_PICKLE_MARKER]) # type: ignore
_verify_type(obj, value.get(_TYPE_MARKER)) # type: ignore
return obj
# Regular dict: decode values recursively
return {k: _decode(v) for k, v in value.items()} # type: ignore
# Handle encoded lists
if isinstance(value, list):
return [_decode(item) for item in value] # type: ignore
return value
def _class_supports_model_protocol(cls: type[Any]) -> bool:
"""Check if a class type supports the model serialization protocol.
def _verify_type(obj: Any, expected_type_key: str) -> None:
"""Verify that an unpickled object matches its recorded type.
Checks for pairs of serialization/deserialization methods:
- to_dict/from_dict
- to_json/from_json
This is a post-deserialization integrity check that detects accidental
corruption or type mismatches. It does not prevent arbitrary code execution
from malicious pickle payloads, since ``pickle.loads()`` has already
executed by the time this function is called.
Args:
obj: The unpickled object.
expected_type_key: The recorded type key (module:qualname format).
Raises:
CheckpointDecodingError: If the types don't match.
"""
has_to_dict = hasattr(cls, "to_dict") and callable(getattr(cls, "to_dict", None))
has_from_dict = hasattr(cls, "from_dict") and callable(getattr(cls, "from_dict", None))
has_to_json = hasattr(cls, "to_json") and callable(getattr(cls, "to_json", None))
has_from_json = hasattr(cls, "from_json") and callable(getattr(cls, "from_json", None))
return (has_to_dict and has_from_dict) or (has_to_json and has_from_json)
actual_type_key = _type_to_key(type(obj)) # type: ignore
if actual_type_key != expected_type_key:
raise CheckpointDecodingError(
f"Type mismatch during checkpoint decoding: "
f"expected '{expected_type_key}', got '{actual_type_key}'. "
f"The checkpoint may be corrupted or tampered with."
)
def _supports_model_protocol(obj: object) -> bool:
"""Detect objects that expose dictionary serialization hooks."""
def _pickle_to_base64(value: Any) -> str:
"""Pickle a value and encode as base64 string."""
pickled = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL)
return base64.b64encode(pickled).decode("ascii")
def _base64_to_unpickle(encoded: str) -> Any:
"""Decode base64 string and unpickle.
Raises:
CheckpointDecodingError: If the base64 data is corrupted or the pickle
format is incompatible.
"""
try:
obj_type: type[Any] = type(obj)
except Exception:
return False
return _class_supports_model_protocol(obj_type)
def _import_qualified_name(qualname: str) -> type[Any] | None:
if ":" not in qualname:
return None
module_name, class_name = qualname.split(":", 1)
module = sys.modules.get(module_name)
if module is None:
module = importlib.import_module(module_name)
attr: Any = module
for part in class_name.split("."):
attr = getattr(attr, part)
return attr if isinstance(attr, type) else None
def _instantiate_checkpoint_dataclass(cls: type[Any], payload: Any) -> Any | None:
if not isinstance(cls, type):
logger.debug(f"Checkpoint decoder received non-type dataclass reference: {cls!r}")
return None
if isinstance(payload, dict):
try:
return cls(**payload) # type: ignore[arg-type]
except TypeError as exc:
logger.debug(f"Checkpoint decoder could not call {cls.__name__}(**payload): {exc}")
except Exception as exc:
logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}(**payload): {exc}")
try:
instance = object.__new__(cls)
except Exception as exc:
logger.debug(f"Checkpoint decoder could not allocate {cls.__name__} without __init__: {exc}")
return None
for key, val in payload.items(): # type: ignore[attr-defined]
try:
setattr(instance, key, val) # type: ignore[arg-type]
except Exception as exc:
logger.debug(f"Checkpoint decoder could not set attribute {key} on {cls.__name__}: {exc}")
return instance
try:
return cls(payload) # type: ignore[call-arg]
except TypeError as exc:
logger.debug(f"Checkpoint decoder could not call {cls.__name__}({payload!r}): {exc}")
pickled = base64.b64decode(encoded.encode("ascii"))
return pickle.loads(pickled) # nosec # noqa: S301
except Exception as exc:
logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}({payload!r}): {exc}")
return None
raise CheckpointDecodingError(f"Failed to decode pickled checkpoint data: {exc}") from exc
def _type_to_key(t: type) -> str:
"""Convert a type to a module:qualname string."""
return f"{t.__module__}:{t.__qualname__}"
@@ -1,49 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import logging
from dataclasses import dataclass
from ._checkpoint import WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
from ._events import WorkflowEvent
logger = logging.getLogger(__name__)
@dataclass
class WorkflowCheckpointSummary:
"""Human-readable summary of a workflow checkpoint."""
checkpoint_id: str
timestamp: str
iteration_count: int
targets: list[str]
executor_ids: list[str]
status: str
pending_request_info_events: list[WorkflowEvent]
def get_checkpoint_summary(checkpoint: WorkflowCheckpoint) -> WorkflowCheckpointSummary:
targets = sorted(checkpoint.messages.keys())
executor_ids = sorted(checkpoint.state.get(EXECUTOR_STATE_KEY, {}).keys())
pending_request_info_events = [
WorkflowEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values()
]
status = "idle"
if pending_request_info_events:
status = "awaiting request response"
elif not checkpoint.messages and "finalise" in executor_ids:
status = "completed"
elif checkpoint.messages:
status = "awaiting next superstep"
return WorkflowCheckpointSummary(
checkpoint_id=checkpoint.checkpoint_id,
timestamp=checkpoint.timestamp,
iteration_count=checkpoint.iteration_count,
targets=targets,
executor_ids=executor_ids,
status=status,
pending_request_info_events=pending_request_info_events,
)
@@ -1,75 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
from collections.abc import Iterable
from typing import Any, cast
from agent_framework import Message
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
"""Utilities for serializing and deserializing chat conversations for persistence.
These helpers convert rich `Message` instances to checkpoint-friendly payloads
using the same encoding primitives as the workflow runner. This preserves
`additional_properties` and other metadata without relying on unsafe mechanisms
such as pickling.
"""
def encode_chat_messages(messages: Iterable[Message]) -> list[dict[str, Any]]:
"""Serialize chat messages into checkpoint-safe payloads."""
encoded: list[dict[str, Any]] = []
for message in messages:
encoded.append({
"role": encode_checkpoint_value(message.role),
"contents": [encode_checkpoint_value(content) for content in message.contents],
"author_name": message.author_name,
"message_id": message.message_id,
"additional_properties": {
key: encode_checkpoint_value(value) for key, value in message.additional_properties.items()
},
})
return encoded
def decode_chat_messages(payload: Iterable[dict[str, Any]]) -> list[Message]:
"""Restore chat messages from checkpoint-safe payloads."""
restored: list[Message] = []
for item in payload:
if not isinstance(item, dict):
continue
role_value = decode_checkpoint_value(item.get("role"))
if isinstance(role_value, str):
role = role_value
elif isinstance(role_value, dict) and "value" in role_value:
# Handle legacy serialization format
role = role_value["value"]
else:
role = "assistant"
contents_field = item.get("contents", [])
contents: list[Any] = []
if isinstance(contents_field, list):
contents_iter: list[Any] = contents_field # type: ignore[assignment]
for entry in contents_iter:
decoded_entry: Any = decode_checkpoint_value(entry)
contents.append(decoded_entry)
additional_field = item.get("additional_properties", {})
additional: dict[str, Any] = {}
if isinstance(additional_field, dict):
additional_dict = cast(dict[str, Any], additional_field)
for key, value in additional_dict.items():
additional[key] = decode_checkpoint_value(value)
restored.append(
Message( # type: ignore[call-overload]
role=role,
contents=contents,
author_name=item.get("author_name"),
message_id=item.get("message_id"),
additional_properties=additional,
)
)
return restored
@@ -12,7 +12,6 @@ from dataclasses import dataclass
from enum import Enum
from typing import Any, Generic, Literal, cast
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._typing_utils import deserialize_type, serialize_type
if sys.version_info >= (3, 13):
@@ -396,7 +395,7 @@ class WorkflowEvent(Generic[DataT]):
raise ValueError(f"to_dict() only supported for 'request_info' events, got '{self.type}'")
return {
"type": self.type,
"data": encode_checkpoint_value(self.data),
"data": self.data,
"request_id": self._request_id,
"source_executor_id": self._source_executor_id,
"request_type": serialize_type(self._request_type) if self._request_type else None,
@@ -410,7 +409,7 @@ class WorkflowEvent(Generic[DataT]):
if prop not in data:
raise KeyError(f"Missing '{prop}' field in WorkflowEvent dictionary.")
request_data = decode_checkpoint_value(data["data"])
request_data = data["data"]
request_type = deserialize_type(data["request_type"])
if request_type is not type(request_data):
@@ -7,12 +7,7 @@ from collections import defaultdict
from collections.abc import AsyncGenerator, Sequence
from typing import Any
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._checkpoint_encoding import (
DATACLASS_MARKER,
MODEL_MARKER,
decode_checkpoint_value,
)
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
from ._const import EXECUTOR_STATE_KEY
from ._edge import EdgeGroup
from ._edge_runner import EdgeRunner, create_edge_runner
@@ -41,8 +36,9 @@ class Runner:
executors: dict[str, Executor],
state: State,
ctx: RunnerContext,
workflow_name: str,
graph_signature_hash: str,
max_iterations: int = 100,
workflow_id: str | None = None,
) -> None:
"""Initialize the runner with edges, state, and context.
@@ -51,24 +47,24 @@ class Runner:
executors: Map of executor IDs to executor instances.
state: The state for the workflow.
ctx: The runner context for the workflow.
workflow_name: The name of the workflow, used for checkpoint labeling.
graph_signature_hash: A hash representing the workflow graph topology for checkpoint validation.
max_iterations: The maximum number of iterations to run.
workflow_id: The workflow ID for checkpointing.
"""
# Workflow instance related attributes
self._executors = executors
self._edge_runners = [create_edge_runner(group, executors) for group in edge_groups]
self._edge_runner_map = self._parse_edge_runners(self._edge_runners)
self._ctx = ctx
self._workflow_name = workflow_name
self._graph_signature_hash = graph_signature_hash
# Runner state related attributes
self._iteration = 0
self._max_iterations = max_iterations
self._state = state
self._workflow_id = workflow_id
self._running = False
self._resumed_from_checkpoint = False # Track whether we resumed
self.graph_signature_hash: str | None = None
# Set workflow ID in context if provided
if workflow_id:
self._ctx.set_workflow_id(workflow_id)
@property
def context(self) -> RunnerContext:
@@ -85,6 +81,7 @@ class Runner:
raise WorkflowRunnerException("Runner is already running.")
self._running = True
previous_checkpoint_id: CheckpointID | None = None
try:
# Emit any events already produced prior to entering loop
if await self._ctx.has_events():
@@ -92,13 +89,12 @@ class Runner:
for event in await self._ctx.drain_events():
yield event
# Create first checkpoint if there are messages from initial execution
if await self._ctx.has_messages() and self._ctx.has_checkpointing():
if not self._resumed_from_checkpoint:
logger.info("Creating checkpoint after initial execution")
await self._create_checkpoint_if_enabled("after_initial_execution")
else:
logger.info("Skipping 'after_initial_execution' checkpoint because we resumed from a checkpoint")
# Create the first checkpoint. Checkpoints are usually considered to be created at the end of an iteration,
# we can think of the first checkpoint as being created at the end of a "superstep 0" which captures the
# states after which the start executor has run. Note that we execute the start executor outside of the
# main iteration loop.
if await self._ctx.has_messages() and not self._resumed_from_checkpoint:
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
while self._iteration < self._max_iterations:
logger.info(f"Starting superstep {self._iteration + 1}")
@@ -145,7 +141,7 @@ class Runner:
self._state.commit()
# Create checkpoint after each superstep iteration
await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}")
previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id)
yield WorkflowEvent.superstep_completed(iteration=self._iteration)
@@ -169,19 +165,6 @@ class Runner:
"""Inner loop to deliver a single message through an edge runner."""
return await edge_runner.send_message(message, self._state, self._ctx)
def _normalize_message_payload(message: WorkflowMessage) -> None:
data = message.data
if not isinstance(data, dict):
return
if MODEL_MARKER not in data and DATACLASS_MARKER not in data:
return
try:
decoded = decode_checkpoint_value(data)
except Exception as exc: # pragma: no cover - defensive
logger.debug("Failed to decode checkpoint payload during delivery: %s", exc)
return
message.data = decoded
# Route all messages through normal workflow edges
associated_edge_runners = self._edge_runner_map.get(source_executor_id, [])
if not associated_edge_runners:
@@ -190,7 +173,6 @@ class Runner:
return
for message in messages:
_normalize_message_payload(message)
# Deliver a message through all edge runners associated with the source executor concurrently.
tasks = [_deliver_message_inner(edge_runner, message) for edge_runner in associated_edge_runners]
await asyncio.gather(*tasks)
@@ -199,35 +181,33 @@ class Runner:
tasks = [_deliver_messages(source_executor_id, messages) for source_executor_id, messages in messages.items()]
await asyncio.gather(*tasks)
async def _create_checkpoint_if_enabled(self, checkpoint_type: str) -> str | None:
async def _create_checkpoint_if_enabled(self, previous_checkpoint_id: CheckpointID | None) -> CheckpointID | None:
"""Create a checkpoint if checkpointing is enabled and attach a label and metadata."""
if not self._ctx.has_checkpointing():
return None
try:
# Snapshot executor states
# Save executor states into the shared state before creating the checkpoint,
# so that they are included in the checkpoint payload.
await self._save_executor_states()
checkpoint_category = "initial" if checkpoint_type == "after_initial_execution" else "superstep"
metadata = {
"superstep": self._iteration,
"checkpoint_type": checkpoint_category,
}
if self.graph_signature_hash:
metadata["graph_signature"] = self.graph_signature_hash
checkpoint_id = await self._ctx.create_checkpoint(
self._workflow_name,
self._graph_signature_hash,
self._state,
previous_checkpoint_id,
self._iteration,
metadata=metadata,
)
logger.info(f"Created {checkpoint_type} checkpoint: {checkpoint_id}")
logger.info(f"Created checkpoint: {checkpoint_id}")
return checkpoint_id
except Exception as e:
logger.warning(f"Failed to create {checkpoint_type} checkpoint: {e}")
logger.warning(f"Failed to create checkpoint: {e}")
return None
async def restore_from_checkpoint(
self,
checkpoint_id: str,
checkpoint_id: CheckpointID,
checkpoint_storage: CheckpointStorage | None = None,
) -> None:
"""Restore workflow state from a checkpoint.
@@ -249,7 +229,7 @@ class Runner:
if self._ctx.has_checkpointing():
checkpoint = await self._ctx.load_checkpoint(checkpoint_id)
elif checkpoint_storage is not None:
checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id)
checkpoint = await checkpoint_storage.load(checkpoint_id)
else:
raise WorkflowCheckpointException(
"Cannot load checkpoint: no checkpointing configured in context or external storage provided."
@@ -260,22 +240,14 @@ class Runner:
raise WorkflowCheckpointException(f"Checkpoint {checkpoint_id} not found")
# Validate the loaded checkpoint against the workflow
graph_hash = getattr(self, "graph_signature_hash", None)
checkpoint_hash = (checkpoint.metadata or {}).get("graph_signature")
if graph_hash and checkpoint_hash and graph_hash != checkpoint_hash:
if self._graph_signature_hash != checkpoint.graph_signature_hash:
raise WorkflowCheckpointException(
"Workflow graph has changed since the checkpoint was created. "
"Please rebuild the original workflow before resuming."
)
if graph_hash and not checkpoint_hash:
logger.warning(
"Checkpoint %s does not include graph signature metadata; skipping topology validation.",
checkpoint_id,
)
self._workflow_id = checkpoint.workflow_id
# Restore state
self._state.import_state(decode_checkpoint_value(checkpoint.state))
self._state.import_state(checkpoint.state)
# Restore executor states using the restored state
await self._restore_executor_states()
# Apply the checkpoint to the context
@@ -291,64 +263,19 @@ class Runner:
raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint_id}") from e
async def _save_executor_states(self) -> None:
"""Populate executor state by calling checkpoint hooks on executors.
Backward compatibility behavior:
- If an executor defines an async or sync method `snapshot_state(self) -> dict`, use it.
- Else if it has a plain attribute `state` that is a dict, use that.
Updated behavior:
- Executors should implement `on_checkpoint_save(self) -> dict` to provide state.
This method will try the backward compatibility behavior first; if that does not yield state,
it falls back to the updated behavior.
Only JSON-serializable dicts should be provided by executors.
"""
"""Populate executor state by calling checkpoint hooks on executors."""
for exec_id, executor in self._executors.items():
state_dict: dict[str, Any] | None = None
# Try backward compatibility behavior first
# TODO(@taochen): Remove backward compatibility
snapshot = getattr(executor, "snapshot_state", None)
try:
if callable(snapshot):
maybe = snapshot()
if asyncio.iscoroutine(maybe): # type: ignore[arg-type]
maybe = await maybe # type: ignore[assignment]
if isinstance(maybe, dict):
state_dict = maybe # type: ignore[assignment]
else:
state_attr = getattr(executor, "state", None)
if isinstance(state_attr, dict):
state_dict = state_attr # type: ignore[assignment]
except Exception as ex: # pragma: no cover
logger.debug(f"Executor {exec_id} snapshot_state failed: {ex}")
if state_dict is None:
# Try the updated behavior only if backward compatibility did not yield state
try:
state_dict = await executor.on_checkpoint_save()
except Exception as ex: # pragma: no cover
raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex
# Try the updated behavior only if backward compatibility did not yield state
try:
state_dict = await executor.on_checkpoint_save()
await self._set_executor_state(exec_id, state_dict)
except WorkflowCheckpointException:
raise
except Exception as ex: # pragma: no cover
logger.debug(f"Failed to persist state for executor {exec_id}: {ex}")
raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex
async def _restore_executor_states(self) -> None:
"""Restore executor state by calling restore hooks on executors.
Backward compatibility behavior:
- If an executor defines an async or sync method `restore_state(self, state: dict)`, use it.
- Else, skip restoration for that executor.
Updated behavior:
- Executors should implement `on_checkpoint_restore(self, state: dict)` to restore state.
This method will try the backward compatibility behavior first; if that does not restore state,
it falls back to the updated behavior.
"""
"""Restore executor state by calling restore hooks on executors."""
has_executor_states = self._state.has(EXECUTOR_STATE_KEY)
if not has_executor_states:
return
@@ -369,29 +296,11 @@ class Runner:
if not executor:
raise WorkflowCheckpointException(f"Executor {executor_id} not found during state restoration.")
# Try backward compatibility behavior first
# TODO(@taochen): Remove backward compatibility
restored = False
restore_method = getattr(executor, "restore_state", None)
# Try the updated behavior only if backward compatibility did not restore
try:
if callable(restore_method):
maybe = restore_method(state)
if asyncio.iscoroutine(maybe): # type: ignore[arg-type]
await maybe # type: ignore[arg-type]
restored = True
await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType]
except Exception as ex: # pragma: no cover - defensive
raise WorkflowCheckpointException(f"Executor {executor_id} restore_state failed") from ex
if not restored:
# Try the updated behavior only if backward compatibility did not restore
try:
await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType]
restored = True
except Exception as ex: # pragma: no cover - defensive
raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex
if not restored:
logger.debug(f"Executor {executor_id} does not support state restoration; skipping.")
raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex
def _parse_edge_runners(self, edge_runners: list[EdgeRunner]) -> dict[str, list[EdgeRunner]]:
"""Parse the edge runners of the workflow into a mapping where each source executor ID maps to its edge runners.
@@ -4,25 +4,17 @@ from __future__ import annotations
import asyncio
import logging
import sys
import uuid
from copy import copy
from dataclasses import dataclass
from enum import Enum
from typing import Any, Protocol, TypeVar, runtime_checkable
from ._checkpoint import CheckpointStorage, WorkflowCheckpoint
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint
from ._const import INTERNAL_SOURCE_ID
from ._events import WorkflowEvent
from ._state import State
from ._typing_utils import is_instance_of
if sys.version_info >= (3, 11):
from typing import TypedDict # type: ignore # pragma: no cover
else:
from typing_extensions import TypedDict # type: ignore # pragma: no cover
logger = logging.getLogger(__name__)
T = TypeVar("T")
@@ -69,13 +61,13 @@ class WorkflowMessage:
def to_dict(self) -> dict[str, Any]:
"""Convert the WorkflowMessage to a dictionary for serialization."""
return {
"data": encode_checkpoint_value(self.data),
"data": self.data,
"source_id": self.source_id,
"target_id": self.target_id,
"type": self.type.value,
"trace_contexts": self.trace_contexts,
"source_span_ids": self.source_span_ids,
"original_request_info_event": encode_checkpoint_value(self.original_request_info_event),
"original_request_info_event": self.original_request_info_event,
}
@staticmethod
@@ -89,28 +81,16 @@ class WorkflowMessage:
raise KeyError("Missing 'source_id' field in WorkflowMessage dictionary.")
return WorkflowMessage(
data=decode_checkpoint_value(data["data"]),
data=data["data"],
source_id=data["source_id"],
target_id=data.get("target_id"),
type=MessageType(data.get("type", "standard")),
trace_contexts=data.get("trace_contexts"),
source_span_ids=data.get("source_span_ids"),
original_request_info_event=decode_checkpoint_value(data.get("original_request_info_event")),
original_request_info_event=data.get("original_request_info_event"),
)
class _WorkflowState(TypedDict):
"""TypedDict representing the serializable state of a workflow execution.
This includes all state data needed for checkpointing and restoration.
"""
messages: dict[str, list[dict[str, Any]]]
state: dict[str, Any]
iteration_count: int
pending_request_info_events: dict[str, dict[str, Any]]
@runtime_checkable
class RunnerContext(Protocol):
"""Protocol for the execution context used by the runner.
@@ -192,11 +172,6 @@ class RunnerContext(Protocol):
"""Clear runtime checkpoint storage override."""
...
# Checkpointing APIs (optional, enabled by storage)
def set_workflow_id(self, workflow_id: str) -> None:
"""Set the workflow ID for the context."""
...
def reset_for_new_run(self) -> None:
"""Reset the context for a new workflow run."""
...
@@ -219,16 +194,23 @@ class RunnerContext(Protocol):
async def create_checkpoint(
self,
workflow_name: str,
graph_signature_hash: str,
state: State,
previous_checkpoint_id: CheckpointID | None,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str:
) -> CheckpointID:
"""Create a checkpoint of the current workflow state.
Args:
workflow_name: The name of the workflow for which the checkpoint is being created.
graph_signature_hash: Hash of the workflow graph topology to
validate checkpoint compatibility during restore.
state: The state to include in the checkpoint.
This is needed to capture the full state of the workflow.
The state is not managed by the context itself.
previous_checkpoint_id: The ID of the previous checkpoint, if any, to form a checkpoint chain.
iteration_count: The current iteration count of the workflow.
metadata: Optional metadata to associate with the checkpoint.
@@ -237,7 +219,7 @@ class RunnerContext(Protocol):
"""
...
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint | None:
"""Load a checkpoint without mutating the current context state.
Args:
@@ -301,7 +283,6 @@ class InProcRunnerContext:
# Checkpointing configuration/state
self._checkpoint_storage = checkpoint_storage
self._runtime_checkpoint_storage: CheckpointStorage | None = None
self._workflow_id: str | None = None
# Streaming flag - set by workflow's run(..., stream=True) vs run(..., stream=False)
self._streaming: bool = False
@@ -376,34 +357,36 @@ class InProcRunnerContext:
async def create_checkpoint(
self,
workflow_name: str,
graph_signature_hash: str,
state: State,
previous_checkpoint_id: CheckpointID | None,
iteration_count: int,
metadata: dict[str, Any] | None = None,
) -> str:
) -> CheckpointID:
storage = self._get_effective_checkpoint_storage()
if not storage:
raise ValueError("Checkpoint storage not configured")
self._workflow_id = self._workflow_id or str(uuid.uuid4())
workflow_state = self._get_serialized_workflow_state(state, iteration_count)
checkpoint = WorkflowCheckpoint(
workflow_id=self._workflow_id,
messages=workflow_state["messages"],
state=workflow_state["state"],
pending_request_info_events=workflow_state["pending_request_info_events"],
iteration_count=workflow_state["iteration_count"],
workflow_name=workflow_name,
graph_signature_hash=graph_signature_hash,
previous_checkpoint_id=previous_checkpoint_id,
messages=dict(self._messages),
state=state.export_state(),
pending_request_info_events=dict(self._pending_request_info_events),
iteration_count=iteration_count,
metadata=metadata or {},
)
checkpoint_id = await storage.save_checkpoint(checkpoint)
logger.info(f"Created checkpoint {checkpoint_id} for workflow {self._workflow_id}")
checkpoint_id = await storage.save(checkpoint)
logger.debug(f"Created checkpoint {checkpoint_id}")
return checkpoint_id
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint:
storage = self._get_effective_checkpoint_storage()
if not storage:
raise ValueError("Checkpoint storage not configured")
return await storage.load_checkpoint(checkpoint_id)
return await storage.load(checkpoint_id)
def reset_for_new_run(self) -> None:
"""Reset the context for a new workflow run.
@@ -422,24 +405,16 @@ class InProcRunnerContext:
self._messages.clear()
messages_data = checkpoint.messages
for source_id, message_list in messages_data.items():
self._messages[source_id] = [WorkflowMessage.from_dict(msg) for msg in message_list]
self._messages[source_id] = list(message_list)
# Restore pending request info events
self._pending_request_info_events.clear()
pending_requests_data = checkpoint.pending_request_info_events
for request_id, request_data in pending_requests_data.items():
request_info_event = WorkflowEvent.from_dict(request_data)
for request_id, request_info_event in checkpoint.pending_request_info_events.items():
self._pending_request_info_events[request_id] = request_info_event
await self.add_event(request_info_event)
# Restore workflow ID
self._workflow_id = checkpoint.workflow_id
# endregion Checkpointing
def set_workflow_id(self, workflow_id: str) -> None:
self._workflow_id = workflow_id
def set_streaming(self, streaming: bool) -> None:
"""Set whether agents should stream incremental updates.
@@ -456,30 +431,14 @@ class InProcRunnerContext:
"""
return self._streaming
def _get_serialized_workflow_state(self, state: State, iteration_count: int) -> _WorkflowState:
serialized_messages: dict[str, list[dict[str, Any]]] = {}
for source_id, message_list in self._messages.items():
serialized_messages[source_id] = [msg.to_dict() for msg in message_list]
serialized_pending_request_info_events: dict[str, dict[str, Any]] = {
request_id: request.to_dict() for request_id, request in self._pending_request_info_events.items()
}
return {
"messages": serialized_messages,
"state": encode_checkpoint_value(state.export_state()),
"iteration_count": iteration_count,
"pending_request_info_events": serialized_pending_request_info_events,
}
async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None:
"""Add a request_info event to the context and track it for correlation.
Args:
event: The WorkflowEvent with type='request_info' to be added.
"""
if event.request_id is None:
raise ValueError("request_info event must have a request_id")
if event.type != "request_info":
raise ValueError("Event type must be 'request_info'")
self._pending_request_info_events[event.request_id] = event
await self.add_event(event)
@@ -175,9 +175,9 @@ class Workflow(DictConvertible):
executors: dict[str, Executor],
start_executor: Executor,
runner_context: RunnerContext,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
name: str | None = None,
name: str,
description: str | None = None,
max_iterations: int = DEFAULT_MAX_ITERATIONS,
output_executors: list[str] | None = None,
**kwargs: Any,
):
@@ -189,8 +189,12 @@ class Workflow(DictConvertible):
start_executor: The starting executor for the workflow.
runner_context: The RunnerContext instance to be used during workflow execution.
max_iterations: The maximum number of iterations the workflow will run for convergence.
name: Optional human-readable name for the workflow.
description: Optional description of what the workflow does.
name: A human-readable name for the workflow. This can be used to identify the workflow in
checkpoints, and telemetry. If the workflow is built using WorkflowBuilder, this will be the
name of the builder. This name should be unique across different workflow definitions for
better observability and management.
description: Optional description of what the workflow does. If the workflow is built using
WorkflowBuilder, this will be the description of the builder.
output_executors: Optional list of executor IDs whose outputs will be considered workflow outputs.
If None or empty, all executor outputs are treated as workflow outputs.
kwargs: Additional keyword arguments. Unused in this implementation.
@@ -199,9 +203,15 @@ class Workflow(DictConvertible):
self.executors = dict(executors)
self.start_executor_id = start_executor.id
self.max_iterations = max_iterations
self.id = str(uuid.uuid4())
self.name = name
self.description = description
# Generate a unique ID for the workflow instance for monitoring purposes. This is not intended to be a
# stable identifier across instances created from the same builder, for that, use the name field.
self.id = str(uuid.uuid4())
# Capture a canonical fingerprint of the workflow graph so checkpoints can assert they are resumed with
# an equivalent topology.
self.graph_signature = self._compute_graph_signature()
self.graph_signature_hash = self._hash_graph_signature(self.graph_signature)
# Output events (WorkflowEvent with type='output') from these executors are treated as workflow outputs.
# If None or empty, all executor outputs are considered workflow outputs.
@@ -215,19 +225,14 @@ class Workflow(DictConvertible):
self.executors,
self._state,
runner_context,
self.name,
self.graph_signature_hash,
max_iterations=max_iterations,
workflow_id=self.id,
)
# Flag to prevent concurrent workflow executions
self._is_running = False
# Capture a canonical fingerprint of the workflow graph so checkpoints
# can assert they are resumed with an equivalent topology.
self._graph_signature = self._compute_graph_signature()
self._graph_signature_hash = self._hash_graph_signature(self._graph_signature)
self._runner.graph_signature_hash = self._graph_signature_hash
def _ensure_not_running(self) -> None:
"""Ensure the workflow is not already running."""
if self._is_running:
@@ -241,6 +246,7 @@ class Workflow(DictConvertible):
def to_dict(self) -> dict[str, Any]:
"""Serialize the workflow definition into a JSON-ready dictionary."""
data: dict[str, Any] = {
"name": self.name,
"id": self.id,
"start_executor_id": self.start_executor_id,
"max_iterations": self.max_iterations,
@@ -249,9 +255,6 @@ class Workflow(DictConvertible):
"output_executors": self._output_executors,
}
# Add optional name and description if provided
if self.name is not None:
data["name"] = self.name
if self.description is not None:
data["description"] = self.description
@@ -565,6 +568,15 @@ class Workflow(DictConvertible):
):
if event.type == "output" and not self._should_yield_output_event(event):
continue
if event.type == "request_info" and event.request_id in (responses or {}):
# Don't yield request_info events for which we have responses to send -
# these are considered "handled". This prevents the caller from seeing
# events for requests they are already responding to.
# This usually happens when responses are provided with a checkpoint
# (restore then send), because the request_info events are stored in the
# checkpoint and would be emitted on restoration by the runner regardless
# of if a response is provided or not.
continue
yield event
async def _run_cleanup(self, checkpoint_storage: CheckpointStorage | None) -> None:
@@ -753,7 +765,7 @@ class Workflow(DictConvertible):
if isinstance(executor, WorkflowExecutor):
executor_sig = {
"type": executor_sig,
"sub_workflow": executor.workflow._graph_signature,
"sub_workflow": executor.workflow.graph_signature,
}
executors_signature[executor_id] = executor_sig
@@ -796,7 +808,6 @@ class Workflow(DictConvertible):
"start_executor": self.start_executor_id,
"executors": executors_signature,
"edge_groups": edge_groups_signature,
"max_iterations": self.max_iterations,
}
@staticmethod
@@ -804,10 +815,6 @@ class Workflow(DictConvertible):
canonical = json.dumps(signature, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
@property
def graph_signature_hash(self) -> str:
return self._graph_signature_hash
@property
def input_types(self) -> list[type[Any] | types.UnionType]:
"""Get the input types of the workflow.
@@ -2,6 +2,7 @@
import logging
import sys
import uuid
from collections.abc import Callable, Sequence
from typing import Any
@@ -88,7 +89,11 @@ class WorkflowBuilder:
Args:
max_iterations: Maximum number of iterations for workflow convergence. Default is 100.
name: Optional human-readable name for the workflow.
name: A human-readable name for the workflow builder. This name will be the identifier
for all workflow instances created from this builder. If not provided, a unique name
will be generated. This will be useful for versioning, monitoring, checkpointing, and
debugging workflows. Keeping this name unique across versions of your workflow definitions
is recommended for better observability and management.
description: Optional description of what the workflow does.
start_executor: The starting executor for the workflow. Can be an Executor instance
or SupportsAgentRun instance.
@@ -101,7 +106,7 @@ class WorkflowBuilder:
self._start_executor: Executor | None = None
self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage
self._max_iterations: int = max_iterations
self._name: str | None = name
self._name: str = name or f"WorkflowBuilder-{uuid.uuid4()!s}"
self._description: str | None = description
# Maps underlying SupportsAgentRun object id -> wrapped Executor so we reuse the same wrapper
# across start_executor / add_edge calls. This avoids multiple AgentExecutor instances
@@ -658,19 +663,18 @@ class WorkflowBuilder:
executors,
start_executor,
context,
self._max_iterations,
name=self._name,
self._name,
description=self._description,
max_iterations=self._max_iterations,
output_executors=output_executors,
)
build_attributes: dict[str, Any] = {
OtelAttr.WORKFLOW_BUILDER_NAME: self._name,
OtelAttr.WORKFLOW_ID: workflow.id,
OtelAttr.WORKFLOW_DEFINITION: workflow.to_json(),
}
if workflow.name:
build_attributes[OtelAttr.WORKFLOW_NAME] = workflow.name
if workflow.description:
build_attributes[OtelAttr.WORKFLOW_DESCRIPTION] = workflow.description
if self._description:
build_attributes[OtelAttr.WORKFLOW_BUILDER_DESCRIPTION] = self._description
span.set_attributes(build_attributes)
# Add workflow build completed event
@@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from ._workflow import Workflow
from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value
from ._checkpoint_encoding import decode_checkpoint_value
from ._const import WORKFLOW_RUN_KWARGS_KEY
from ._events import (
WorkflowEvent,
@@ -454,8 +454,7 @@ class WorkflowExecutor(Executor):
"""Get the current state of the WorkflowExecutor for checkpointing purposes."""
return {
"execution_contexts": {
execution_id: encode_checkpoint_value(execution_context)
for execution_id, execution_context in self._execution_contexts.items()
execution_id: execution_context for execution_id, execution_context in self._execution_contexts.items()
},
"request_to_execution": dict(self._request_to_execution),
}
@@ -654,21 +653,6 @@ class WorkflowExecutor(Executor):
try:
# Resume the sub-workflow with all collected responses
result = await self.workflow.run(responses=responses_to_send)
# Remove handled requests from result. The result may contain the original
# RequestInfoEvents that were already handled. This is due to checkpointing
# and rehydration of the workflow that re-adds the RequestInfoEvents to the
# workflow's _runner_context thus the event queue. When the workflow is resumed,
# those events will be emitted at the very beginning of the superstep, prior to
# processing messages/responses, creating the illusion that the workflow is
# requesting the same information again.
for request_id in responses_to_send:
event_to_remove = next(
(event for event in result if event.type == "request_info" and event.request_id == request_id),
None,
)
if event_to_remove:
result.remove(event_to_remove)
# Process the workflow result using shared logic
await self._process_workflow_result(result, execution_context, ctx)
finally:
@@ -196,6 +196,8 @@ class OtelAttr(str, Enum):
# Workflow attributes
WORKFLOW_ID = "workflow.id"
WORKFLOW_BUILDER_NAME = "workflow_builder.name"
WORKFLOW_BUILDER_DESCRIPTION = "workflow_builder.description"
WORKFLOW_NAME = "workflow.name"
WORKFLOW_DESCRIPTION = "workflow.description"
WORKFLOW_DEFINITION = "workflow.definition"
@@ -84,16 +84,17 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None:
assert initial_agent.call_count == 1
# Verify checkpoint was created
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) > 0
# Find a suitable checkpoint to restore (prefer superstep checkpoint)
checkpoints.sort(key=lambda cp: cp.timestamp)
restore_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) >= 2, (
"Expected at least 2 checkpoints. The first one is after the start executor, "
"and the second one is after the agent execution."
)
# Get the second checkpoint which should contain the state after processing
# the first message by the start executor in the sequential workflow
checkpoints.sort(key=lambda cp: cp.timestamp)
restore_checkpoint = checkpoints[1]
# Verify checkpoint contains executor state with both cache and thread
assert "_executor_state" in restore_checkpoint.state
executor_states = restore_checkpoint.state["_executor_state"]
File diff suppressed because it is too large Load Diff
@@ -1,16 +1,17 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass # noqa: I001
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, cast
import pytest
from agent_framework._workflows._checkpoint_encoding import (
DATACLASS_MARKER,
MODEL_MARKER,
_TYPE_MARKER, # type: ignore
CheckpointDecodingError,
decode_checkpoint_value,
encode_checkpoint_value,
)
from agent_framework._workflows._typing_utils import is_instance_of
@dataclass
@@ -30,7 +31,22 @@ class SampleResponse:
request_id: str
def test_decode_dataclass_with_nested_request() -> None:
# --- Tests for round-trip encode/decode ---
def test_roundtrip_simple_dataclass() -> None:
"""Test encoding and decoding of a simple dataclass."""
original = SampleRequest(request_id="test-123", prompt="test prompt")
encoded = encode_checkpoint_value(original)
decoded = cast(SampleRequest, decode_checkpoint_value(encoded))
assert isinstance(decoded, SampleRequest)
assert decoded.request_id == "test-123"
assert decoded.prompt == "test prompt"
def test_roundtrip_dataclass_with_nested_request() -> None:
"""Test that dataclass with nested dataclass fields can be encoded and decoded correctly."""
original = SampleResponse(
data="approve",
@@ -49,45 +65,7 @@ def test_decode_dataclass_with_nested_request() -> None:
assert decoded.original_request.request_id == "abc"
def test_is_instance_of_coerces_nested_dataclass_dict() -> None:
"""Test that is_instance_of can handle nested structures with dict conversion."""
response = SampleResponse(
data="approve",
original_request=SampleRequest(request_id="req-1", prompt="prompt"),
request_id="req-1",
)
# Simulate checkpoint decode fallback leaving a dict
response.original_request = cast(
Any,
{
"request_id": "req-1",
"prompt": "prompt",
},
)
assert is_instance_of(response, SampleResponse)
assert isinstance(response.original_request, dict)
# Verify the dict contains expected values
dict_request = cast(dict[str, Any], response.original_request)
assert dict_request["request_id"] == "req-1"
assert dict_request["prompt"] == "prompt"
def test_encode_decode_simple_dataclass() -> None:
"""Test encoding and decoding of a simple dataclass."""
original = SampleRequest(request_id="test-123", prompt="test prompt")
encoded = encode_checkpoint_value(original)
decoded = cast(SampleRequest, decode_checkpoint_value(encoded))
assert isinstance(decoded, SampleRequest)
assert decoded.request_id == "test-123"
assert decoded.prompt == "test prompt"
def test_encode_decode_nested_structures() -> None:
def test_roundtrip_nested_structures() -> None:
"""Test encoding and decoding of complex nested structures."""
nested_data = {
"requests": [
@@ -110,7 +88,6 @@ def test_encode_decode_nested_structures() -> None:
assert "requests" in decoded
assert "responses" in decoded
# Check the requests list
requests = cast(list[Any], decoded["requests"])
assert isinstance(requests, list)
assert len(requests) == 2
@@ -120,7 +97,6 @@ def test_encode_decode_nested_structures() -> None:
assert first_request.request_id == "req-1"
assert second_request.request_id == "req-2"
# Check the responses dict
responses = cast(dict[str, Any], decoded["responses"])
assert isinstance(responses, dict)
assert "req-1" in responses
@@ -131,108 +107,145 @@ def test_encode_decode_nested_structures() -> None:
assert response.original_request.request_id == "req-1"
def test_encode_allows_marker_key_without_value_key() -> None:
"""Test that encoding a dict with only the marker key (no 'value') is allowed."""
dict_with_marker_only = {
MODEL_MARKER: "some.module:FakeClass",
"other_key": "test",
def test_roundtrip_datetime() -> None:
"""Test round-trip encoding/decoding of datetime objects."""
original = datetime(2024, 5, 4, 12, 30, 45, tzinfo=timezone.utc)
encoded = encode_checkpoint_value(original)
decoded = decode_checkpoint_value(encoded)
assert isinstance(decoded, datetime)
assert decoded == original
def test_roundtrip_primitives() -> None:
"""Test that primitive types round-trip unchanged."""
for value in ["hello", 42, 3.14, True, False, None]:
assert decode_checkpoint_value(encode_checkpoint_value(value)) == value
def test_roundtrip_dict_with_mixed_values() -> None:
"""Test round-trip of a dict containing both primitives and complex types."""
original = {
"name": "test",
"request": SampleRequest(request_id="r1", prompt="p1"),
"count": 5,
}
encoded = encode_checkpoint_value(dict_with_marker_only)
assert MODEL_MARKER in encoded
assert "other_key" in encoded
encoded = encode_checkpoint_value(original)
decoded = decode_checkpoint_value(encoded)
assert decoded["name"] == "test"
assert decoded["count"] == 5
assert isinstance(decoded["request"], SampleRequest)
assert decoded["request"].request_id == "r1"
def test_encode_allows_value_key_without_marker_key() -> None:
"""Test that encoding a dict with only 'value' key (no marker) is allowed."""
dict_with_value_only = {
"value": {"data": "test"},
"other_key": "test",
}
encoded = encode_checkpoint_value(dict_with_value_only)
assert "value" in encoded
assert "other_key" in encoded
# --- Tests for decode primitives ---
def test_encode_allows_marker_with_value_key() -> None:
"""Test that encoding a dict with marker and 'value' keys is allowed.
This is allowed because legitimate encoded data may contain these keys,
and security is enforced at deserialization time by validating class types.
"""
dict_with_both = {
MODEL_MARKER: "some.module:SomeClass",
"value": {"data": "test"},
"strategy": "to_dict",
}
encoded = encode_checkpoint_value(dict_with_both)
assert MODEL_MARKER in encoded
assert "value" in encoded
def test_decode_string() -> None:
"""Test decoding a string passes through unchanged."""
assert decode_checkpoint_value("hello") == "hello"
class NotADataclass:
def test_decode_integer() -> None:
"""Test decoding an integer passes through unchanged."""
assert decode_checkpoint_value(42) == 42
def test_decode_none() -> None:
"""Test decoding None passes through unchanged."""
assert decode_checkpoint_value(None) is None
# --- Tests for decode collections ---
def test_decode_plain_dict() -> None:
"""Test decoding a plain dictionary with primitive values."""
data = {"a": 1, "b": "two"}
assert decode_checkpoint_value(data) == {"a": 1, "b": "two"}
def test_decode_plain_list() -> None:
"""Test decoding a plain list with primitive values."""
data = [1, "two", 3.0]
assert decode_checkpoint_value(data) == [1, "two", 3.0]
# --- Tests for type verification ---
def test_decode_raises_on_type_mismatch() -> None:
"""Test that decoding raises CheckpointDecodingError when type doesn't match."""
# Encode a SampleRequest but tamper with the type marker
encoded = encode_checkpoint_value(SampleRequest(request_id="r1", prompt="p1"))
assert isinstance(encoded, dict)
encoded[_TYPE_MARKER] = "nonexistent.module:FakeClass"
with pytest.raises(CheckpointDecodingError, match="Type mismatch"):
decode_checkpoint_value(encoded)
class NotADataclass: # noqa: B903
"""A regular class that is not a dataclass."""
def __init__(self, value: str) -> None:
self.value = value
def get_value(self) -> str:
return self.value
def test_roundtrip_regular_class() -> None:
"""Test that regular (non-dataclass) objects can be round-tripped via pickle."""
original = NotADataclass(value="test_value")
encoded = encode_checkpoint_value(original)
decoded = cast(NotADataclass, decode_checkpoint_value(encoded))
assert isinstance(decoded, NotADataclass)
assert decoded.value == "test_value"
class NotAModel:
"""A regular class that does not support the model protocol."""
def test_roundtrip_tuple() -> None:
"""Test that tuples preserve their type through encode/decode roundtrip."""
original = (1, "two", 3.0)
def __init__(self, value: str) -> None:
self.value = value
encoded = encode_checkpoint_value(original)
decoded = decode_checkpoint_value(encoded)
def get_value(self) -> str:
return self.value
assert isinstance(decoded, tuple)
assert decoded == original
def test_decode_rejects_non_dataclass_with_dataclass_marker() -> None:
"""Test that decode returns raw value when marked class is not a dataclass."""
# Manually construct a payload that claims NotADataclass is a dataclass
fake_payload = {
DATACLASS_MARKER: f"{NotADataclass.__module__}:{NotADataclass.__name__}",
"value": {"value": "test_value"},
}
def test_roundtrip_set() -> None:
"""Test that sets preserve their type through encode/decode roundtrip."""
original = {1, 2, 3}
decoded = decode_checkpoint_value(fake_payload)
encoded = encode_checkpoint_value(original)
decoded = decode_checkpoint_value(encoded)
# Should return the raw decoded value, not an instance of NotADataclass
assert isinstance(decoded, dict)
assert decoded["value"] == "test_value"
assert isinstance(decoded, set)
assert decoded == original
def test_decode_rejects_non_model_with_model_marker() -> None:
"""Test that decode returns raw value when marked class doesn't support model protocol."""
# Manually construct a payload that claims NotAModel supports the model protocol
fake_payload = {
MODEL_MARKER: f"{NotAModel.__module__}:{NotAModel.__name__}",
"strategy": "to_dict",
"value": {"value": "test_value"},
}
def test_roundtrip_nested_tuple_in_dict() -> None:
"""Test that tuples nested inside dicts preserve their type."""
original = {"items": (1, 2, 3), "name": "test"}
decoded = decode_checkpoint_value(fake_payload)
encoded = encode_checkpoint_value(original)
decoded = decode_checkpoint_value(encoded)
# Should return the raw decoded value, not an instance of NotAModel
assert isinstance(decoded, dict)
assert decoded["value"] == "test_value"
assert isinstance(decoded["items"], tuple)
assert decoded["items"] == (1, 2, 3)
assert decoded["name"] == "test"
def test_encode_allows_nested_dict_with_marker_keys() -> None:
"""Test that encoding allows nested dicts containing marker patterns.
def test_roundtrip_set_in_list() -> None:
"""Test that sets nested inside lists preserve their type."""
original = [{"tags": {1, 2, 3}}]
Security is enforced at deserialization time, not serialization time,
so legitimate encoded data can contain markers at any nesting level.
"""
nested_data = {
"outer": {
MODEL_MARKER: "some.module:SomeClass",
"value": {"data": "test"},
}
}
encoded = encode_checkpoint_value(original)
decoded = decode_checkpoint_value(encoded)
encoded = encode_checkpoint_value(nested_data)
assert "outer" in encoded
assert MODEL_MARKER in encoded["outer"]
assert isinstance(decoded[0]["tags"], set)
assert decoded[0]["tags"] == {1, 2, 3}
@@ -1,12 +1,13 @@
# Copyright (c) Microsoft. All rights reserved.
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
from agent_framework._workflows._checkpoint_encoding import (
_CYCLE_SENTINEL,
DATACLASS_MARKER,
MODEL_MARKER,
_PICKLE_MARKER,
_TYPE_MARKER,
encode_checkpoint_value,
)
@@ -41,23 +42,6 @@ class ModelWithToDict:
return cls(data=d["data"])
class ModelWithToJson:
"""A class that implements to_json/from_json protocol."""
def __init__(self, data: str) -> None:
self.data = data
def to_json(self) -> str:
return f'{{"data": "{self.data}"}}'
@classmethod
def from_json(cls, json_str: str) -> "ModelWithToJson":
import json
d = json.loads(json_str)
return cls(data=d["data"])
class UnknownObject:
"""A class that doesn't support any serialization protocol."""
@@ -68,43 +52,37 @@ class UnknownObject:
return f"UnknownObject({self.value})"
# --- Tests for primitive encoding ---
# --- Tests for primitive encoding (pass-through) ---
def test_encode_string() -> None:
"""Test encoding a string value."""
result = encode_checkpoint_value("hello")
assert result == "hello"
assert encode_checkpoint_value("hello") == "hello"
def test_encode_integer() -> None:
"""Test encoding an integer value."""
result = encode_checkpoint_value(42)
assert result == 42
assert encode_checkpoint_value(42) == 42
def test_encode_float() -> None:
"""Test encoding a float value."""
result = encode_checkpoint_value(3.14)
assert result == 3.14
assert encode_checkpoint_value(3.14) == 3.14
def test_encode_boolean_true() -> None:
"""Test encoding a True boolean value."""
result = encode_checkpoint_value(True)
assert result is True
assert encode_checkpoint_value(True) is True
def test_encode_boolean_false() -> None:
"""Test encoding a False boolean value."""
result = encode_checkpoint_value(False)
assert result is False
assert encode_checkpoint_value(False) is False
def test_encode_none() -> None:
"""Test encoding a None value."""
result = encode_checkpoint_value(None)
assert result is None
assert encode_checkpoint_value(None) is None
# --- Tests for collection encoding ---
@@ -112,8 +90,7 @@ def test_encode_none() -> None:
def test_encode_empty_dict() -> None:
"""Test encoding an empty dictionary."""
result = encode_checkpoint_value({})
assert result == {}
assert encode_checkpoint_value({}) == {}
def test_encode_simple_dict() -> None:
@@ -132,8 +109,7 @@ def test_encode_dict_with_non_string_keys() -> None:
def test_encode_empty_list() -> None:
"""Test encoding an empty list."""
result = encode_checkpoint_value([])
assert result == []
assert encode_checkpoint_value([]) == []
def test_encode_simple_list() -> None:
@@ -144,29 +120,26 @@ def test_encode_simple_list() -> None:
def test_encode_tuple() -> None:
"""Test encoding a tuple (converted to list)."""
"""Test encoding a tuple (pickled to preserve type)."""
data = (1, 2, 3)
result = encode_checkpoint_value(data)
assert result == [1, 2, 3]
assert isinstance(result, dict)
assert _PICKLE_MARKER in result
assert _TYPE_MARKER in result
def test_encode_set() -> None:
"""Test encoding a set (converted to list)."""
"""Test encoding a set (pickled to preserve type)."""
data = {1, 2, 3}
result = encode_checkpoint_value(data)
assert isinstance(result, list)
assert sorted(result) == [1, 2, 3]
assert isinstance(result, dict)
assert _PICKLE_MARKER in result
assert _TYPE_MARKER in result
def test_encode_nested_dict() -> None:
"""Test encoding a nested dictionary structure."""
data = {
"outer": {
"inner": {
"value": 42,
}
}
}
data = {"outer": {"inner": {"value": 42}}}
result = encode_checkpoint_value(data)
assert result == {"outer": {"inner": {"value": 42}}}
@@ -178,18 +151,18 @@ def test_encode_list_of_dicts() -> None:
assert result == [{"a": 1}, {"b": 2}]
# --- Tests for dataclass encoding ---
# --- Tests for non-JSON-native types (pickled) ---
def test_encode_simple_dataclass() -> None:
"""Test encoding a simple dataclass."""
"""Test encoding a simple dataclass produces a pickled entry."""
obj = SimpleDataclass(name="test", value=42)
result = encode_checkpoint_value(obj)
assert isinstance(result, dict)
assert DATACLASS_MARKER in result
assert "value" in result
assert result["value"] == {"name": "test", "value": 42}
assert _PICKLE_MARKER in result
assert _TYPE_MARKER in result
assert isinstance(result[_PICKLE_MARKER], str) # base64 string
def test_encode_nested_dataclass() -> None:
@@ -199,12 +172,8 @@ def test_encode_nested_dataclass() -> None:
result = encode_checkpoint_value(outer)
assert isinstance(result, dict)
assert DATACLASS_MARKER in result
assert "value" in result
outer_value = result["value"]
assert outer_value["outer_name"] == "outer"
assert DATACLASS_MARKER in outer_value["inner"]
assert _PICKLE_MARKER in result
assert _TYPE_MARKER in result
def test_encode_list_of_dataclasses() -> None:
@@ -218,7 +187,7 @@ def test_encode_list_of_dataclasses() -> None:
assert isinstance(result, list)
assert len(result) == 2
for item in result:
assert DATACLASS_MARKER in item
assert _PICKLE_MARKER in item
def test_encode_dict_with_dataclass_values() -> None:
@@ -230,169 +199,77 @@ def test_encode_dict_with_dataclass_values() -> None:
result = encode_checkpoint_value(data)
assert isinstance(result, dict)
assert DATACLASS_MARKER in result["item1"]
assert DATACLASS_MARKER in result["item2"]
# --- Tests for model protocol encoding ---
assert _PICKLE_MARKER in result["item1"]
assert _PICKLE_MARKER in result["item2"]
def test_encode_model_with_to_dict() -> None:
"""Test encoding an object implementing to_dict/from_dict protocol."""
"""Test encoding an object with to_dict is pickled (not using to_dict)."""
obj = ModelWithToDict(data="test_data")
result = encode_checkpoint_value(obj)
assert isinstance(result, dict)
assert MODEL_MARKER in result
assert result["strategy"] == "to_dict"
assert result["value"] == {"data": "test_data"}
assert _PICKLE_MARKER in result
def test_encode_model_with_to_json() -> None:
"""Test encoding an object implementing to_json/from_json protocol."""
obj = ModelWithToJson(data="test_data")
result = encode_checkpoint_value(obj)
assert isinstance(result, dict)
assert MODEL_MARKER in result
assert result["strategy"] == "to_json"
assert '"data": "test_data"' in result["value"]
# --- Tests for unknown object encoding ---
def test_encode_unknown_object_fallback_to_string() -> None:
"""Test that unknown objects are encoded as strings."""
def test_encode_unknown_object() -> None:
"""Test that arbitrary objects are pickled."""
obj = UnknownObject(value="test")
result = encode_checkpoint_value(obj)
assert isinstance(result, str)
assert "UnknownObject" in result
assert isinstance(result, dict)
assert _PICKLE_MARKER in result
# --- Tests for cycle detection ---
def test_encode_datetime() -> None:
"""Test that datetime objects are pickled."""
dt = datetime(2024, 5, 4, 12, 30, 45, tzinfo=timezone.utc)
result = encode_checkpoint_value(dt)
assert isinstance(result, dict)
assert _PICKLE_MARKER in result
def test_encode_dict_with_self_reference() -> None:
"""Test that dict self-references are detected and handled."""
data: dict[str, Any] = {"name": "test"}
data["self"] = data # Create circular reference
result = encode_checkpoint_value(data)
assert result["name"] == "test"
assert result["self"] == _CYCLE_SENTINEL
# --- Tests for type marker ---
def test_encode_list_with_self_reference() -> None:
"""Test that list self-references are detected and handled."""
data: list[Any] = [1, 2]
data.append(data) # Create circular reference
def test_encode_type_marker_records_type_info() -> None:
"""Test that encoded objects include correct type information."""
obj = SimpleDataclass(name="test", value=42)
result = encode_checkpoint_value(obj)
result = encode_checkpoint_value(data)
assert result[0] == 1
assert result[1] == 2
assert result[2] == _CYCLE_SENTINEL
type_key = result[_TYPE_MARKER]
assert "SimpleDataclass" in type_key
# --- Tests for reserved keyword handling ---
# Note: Security is enforced at deserialization time by validating class types,
# not at serialization time. This allows legitimate encoded data to be re-encoded.
def test_encode_type_marker_uses_module_qualname_format() -> None:
"""Test that type marker uses module:qualname format."""
obj = SimpleDataclass(name="test", value=42)
result = encode_checkpoint_value(obj)
type_key = result[_TYPE_MARKER]
assert ":" in type_key
module, qualname = type_key.split(":")
assert module # non-empty module
assert qualname == "SimpleDataclass"
def test_encode_allows_dict_with_model_marker_and_value() -> None:
"""Test that encoding a dict with MODEL_MARKER and 'value' is allowed.
# --- Tests for JSON serializability ---
Security is enforced at deserialization time, not serialization time.
"""
def test_encode_result_is_json_serializable() -> None:
"""Test that encoded output is fully JSON-serializable."""
data = {
MODEL_MARKER: "some.module:SomeClass",
"value": {"data": "test"},
"dc": SimpleDataclass(name="test", value=42),
"model": ModelWithToDict(data="test"),
"dt": datetime.now(timezone.utc),
"nested": [SimpleDataclass(name="n", value=1)],
}
result = encode_checkpoint_value(data)
assert MODEL_MARKER in result
assert "value" in result
def test_encode_allows_dict_with_dataclass_marker_and_value() -> None:
"""Test that encoding a dict with DATACLASS_MARKER and 'value' is allowed.
Security is enforced at deserialization time, not serialization time.
"""
data = {
DATACLASS_MARKER: "some.module:SomeClass",
"value": {"field": "test"},
}
result = encode_checkpoint_value(data)
assert DATACLASS_MARKER in result
assert "value" in result
def test_encode_allows_nested_dict_with_marker_keys() -> None:
"""Test that encoding nested dict with marker keys is allowed.
Security is enforced at deserialization time, not serialization time.
"""
nested_data = {
"outer": {
MODEL_MARKER: "some.module:SomeClass",
"value": {"data": "test"},
}
}
result = encode_checkpoint_value(nested_data)
assert "outer" in result
assert MODEL_MARKER in result["outer"]
def test_encode_allows_marker_without_value() -> None:
"""Test that a dict with marker key but without 'value' key is allowed."""
data = {
MODEL_MARKER: "some.module:SomeClass",
"other_key": "allowed",
}
result = encode_checkpoint_value(data)
assert MODEL_MARKER in result
assert result["other_key"] == "allowed"
def test_encode_allows_value_without_marker() -> None:
"""Test that a dict with 'value' key but without marker is allowed."""
data = {
"value": {"nested": "data"},
"other_key": "allowed",
}
result = encode_checkpoint_value(data)
assert "value" in result
assert result["other_key"] == "allowed"
# --- Tests for max depth protection ---
def test_encode_deep_nesting_triggers_max_depth() -> None:
"""Test that very deep nesting triggers max depth protection."""
# Create a deeply nested structure (over 100 levels)
data: dict[str, Any] = {"level": 0}
current = data
for i in range(105):
current["nested"] = {"level": i + 1}
current = current["nested"]
result = encode_checkpoint_value(data)
# Navigate to find the max_depth sentinel
current_result = result
found_max_depth = False
for _ in range(110):
if isinstance(current_result, dict) and "nested" in current_result:
current_result = current_result["nested"]
if current_result == "<max_depth>":
found_max_depth = True
break
else:
break
assert found_max_depth, "Expected <max_depth> sentinel to be found in deeply nested structure"
# Should not raise
json_str = json.dumps(result)
assert isinstance(json_str, str)
# --- Tests for mixed complex structures ---
@@ -413,6 +290,7 @@ def test_encode_complex_mixed_structure() -> None:
result = encode_checkpoint_value(data)
# Primitives and collections pass through
assert result["string_value"] == "hello"
assert result["int_value"] == 42
assert result["float_value"] == 3.14
@@ -420,4 +298,17 @@ def test_encode_complex_mixed_structure() -> None:
assert result["none_value"] is None
assert result["list_value"] == [1, 2, 3]
assert result["nested_dict"] == {"a": 1, "b": 2}
assert DATACLASS_MARKER in result["dataclass_value"]
# Dataclass is pickled
assert _PICKLE_MARKER in result["dataclass_value"]
def test_encode_preserves_dict_with_pickle_marker_key() -> None:
"""Test that regular dicts containing _PICKLE_MARKER key are recursively encoded."""
data = {
_PICKLE_MARKER: "some_value",
"other_key": "test",
}
result = encode_checkpoint_value(data)
assert _PICKLE_MARKER in result
assert result[_PICKLE_MARKER] == "some_value"
assert result["other_key"] == "test"
@@ -44,7 +44,7 @@ async def test_resume_fails_when_graph_mismatch() -> None:
# Run once to create checkpoints
_ = [event async for event in workflow.run("hello", stream=True)] # noqa: F841
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
assert checkpoints, "expected at least one checkpoint to be created"
target_checkpoint = checkpoints[-1]
@@ -67,7 +67,7 @@ async def test_resume_succeeds_when_graph_matches() -> None:
workflow = build_workflow(storage, finish_id="finish")
_ = [event async for event in workflow.run("hello", stream=True)] # noqa: F841
checkpoints = sorted(await storage.list_checkpoints(), key=lambda c: c.timestamp)
checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow.name), key=lambda c: c.timestamp)
target_checkpoint = checkpoints[0]
resumed_workflow = build_workflow(storage, finish_id="finish")
@@ -126,7 +126,7 @@ async def test_resume_succeeds_when_sub_workflow_matches() -> None:
_ = [event async for event in workflow.run("hello", stream=True)]
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
assert checkpoints, "expected at least one checkpoint to be created"
target_checkpoint = checkpoints[-1]
@@ -150,7 +150,7 @@ async def test_resume_fails_when_sub_workflow_changes() -> None:
_ = [event async for event in workflow.run("hello", stream=True)]
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
assert checkpoints, "expected at least one checkpoint to be created"
target_checkpoint = checkpoints[-1]
@@ -3,7 +3,6 @@
from dataclasses import dataclass
from agent_framework import (
FileCheckpointStorage,
WorkflowBuilder,
WorkflowContext,
WorkflowEvent,
@@ -323,90 +322,3 @@ class TestRequestInfoAndResponse:
assert completed
# Should not have any calculations performed due to invalid input
assert len(executor.calculations_performed) == 0
async def test_checkpoint_with_pending_request_info_events(self):
"""Test that request info events are properly serialized in checkpoints and can be restored."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir:
# Use file-based storage to test full serialization
storage = FileCheckpointStorage(temp_dir)
# Create workflow with checkpointing enabled
executor = ApprovalRequiredExecutor(id="approval_executor")
workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
# Step 1: Run workflow to completion to ensure checkpoints are created
request_info_event: WorkflowEvent | None = None
async for event in workflow.run("checkpoint test operation", stream=True):
if event.type == "request_info":
request_info_event = event
# Verify request was emitted
assert request_info_event is not None
assert isinstance(request_info_event.data, UserApprovalRequest)
assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation"
assert request_info_event.source_executor_id == "approval_executor"
# Step 2: List checkpoints to find the one with our pending request
checkpoints = await storage.list_checkpoints()
assert len(checkpoints) > 0, "No checkpoints were created during workflow execution"
# Find the checkpoint with our pending request
checkpoint_with_request = None
for checkpoint in checkpoints:
if request_info_event.request_id in checkpoint.pending_request_info_events:
checkpoint_with_request = checkpoint
break
assert checkpoint_with_request is not None, "No checkpoint found with pending request info event"
# Step 3: Verify the pending request info event was properly serialized
serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id]
assert "data" in serialized_event
assert "request_id" in serialized_event
assert "source_executor_id" in serialized_event
assert "request_type" in serialized_event
assert serialized_event["request_id"] == request_info_event.request_id
assert serialized_event["source_executor_id"] == "approval_executor"
# Step 4: Create a fresh workflow and restore from checkpoint
new_executor = ApprovalRequiredExecutor(id="approval_executor")
restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
# Step 5: Resume from checkpoint and verify the request can be continued
completed = False
restored_request_event: WorkflowEvent | None = None
async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True):
# Should re-emit the pending request info event
if event.type == "request_info" and event.request_id == request_info_event.request_id:
restored_request_event = event
elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
completed = True
assert completed, "Workflow should reach idle with pending requests state after restoration"
assert restored_request_event is not None, "Restored request info event should be emitted"
# Verify the restored event matches the original
assert restored_request_event.source_executor_id == request_info_event.source_executor_id
assert isinstance(restored_request_event.data, UserApprovalRequest)
assert restored_request_event.data.prompt == request_info_event.data.prompt
assert restored_request_event.data.context == request_info_event.data.context
# Step 6: Provide response to the restored request and complete the workflow
final_completed = False
async for event in restored_workflow.run(
stream=True,
responses={
request_info_event.request_id: True # Approve the request
},
):
if event.type == "status" and event.state == WorkflowRunState.IDLE:
final_completed = True
assert final_completed, "Workflow should complete after providing response to restored request"
# Step 7: Verify the executor state was properly restored and response was processed
assert new_executor.approval_received is True
expected_result = "Operation approved: Please approve the operation: checkpoint test operation"
assert new_executor.final_result == expected_result
@@ -4,14 +4,27 @@ import json
from dataclasses import dataclass, field
from datetime import datetime, timezone
import pytest
from agent_framework import InMemoryCheckpointStorage, InProcRunnerContext
from agent_framework._workflows._checkpoint_encoding import DATACLASS_MARKER, encode_checkpoint_value
from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary
from agent_framework import (
FileCheckpointStorage,
InMemoryCheckpointStorage,
InProcRunnerContext,
WorkflowBuilder,
WorkflowRunState,
)
from agent_framework._workflows._checkpoint_encoding import (
_PICKLE_MARKER,
encode_checkpoint_value,
)
from agent_framework._workflows._events import WorkflowEvent
from agent_framework._workflows._state import State
from .test_request_info_and_response import (
ApprovalRequiredExecutor,
CalculationRequest,
MultiRequestExecutor,
UserApprovalRequest,
)
@dataclass
class MockRequest: ...
@@ -46,13 +59,13 @@ async def test_rehydrate_request_info_event() -> None:
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint_id = await runner_context.create_checkpoint("test_name", "test_hash", State(), None, iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
assert checkpoint.pending_request_info_events
assert "request-123" in checkpoint.pending_request_info_events
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
assert checkpoint.pending_request_info_events["request-123"].request_type is MockRequest
# Rehydrate the context
await runner_context.apply_checkpoint(checkpoint)
@@ -67,97 +80,6 @@ async def test_rehydrate_request_info_event() -> None:
assert isinstance(rehydrated_event.data, MockRequest)
async def test_rehydrate_fails_when_request_type_missing() -> None:
"""Rehydration should fail is the request type is missing or fails to import."""
request_info_event = WorkflowEvent.request_info(
request_id="request-123",
source_executor_id="review_gateway",
request_data=MockRequest(),
response_type=bool,
)
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
assert checkpoint.pending_request_info_events
assert "request-123" in checkpoint.pending_request_info_events
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
# Modify the checkpoint to simulate missing request type
checkpoint.pending_request_info_events["request-123"]["request_type"] = "nonexistent.module:MissingRequest"
# Rehydrate the context
with pytest.raises(ImportError):
await runner_context.apply_checkpoint(checkpoint)
async def test_rehydrate_fails_when_request_type_mismatch() -> None:
"""Rehydration should fail if the request type is mismatched."""
request_info_event = WorkflowEvent.request_info(
request_id="request-123",
source_executor_id="review_gateway",
request_data=MockRequest(),
response_type=bool,
)
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
assert checkpoint.pending_request_info_events
assert "request-123" in checkpoint.pending_request_info_events
assert "request_type" in checkpoint.pending_request_info_events["request-123"]
# Modify the checkpoint to simulate mismatched request type in the serialized data
checkpoint.pending_request_info_events["request-123"]["data"][DATACLASS_MARKER] = (
"nonexistent.module:MissingRequest"
)
# Rehydrate the context
with pytest.raises(TypeError):
await runner_context.apply_checkpoint(checkpoint)
async def test_pending_requests_in_summary() -> None:
"""Test that pending requests are correctly summarized in the checkpoint summary."""
request_info_event = WorkflowEvent.request_info(
request_id="request-123",
source_executor_id="review_gateway",
request_data=MockRequest(),
response_type=bool,
)
runner_context = InProcRunnerContext(InMemoryCheckpointStorage())
await runner_context.add_request_info_event(request_info_event)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
assert checkpoint is not None
summary = get_checkpoint_summary(checkpoint)
assert summary.checkpoint_id == checkpoint_id
assert summary.status == "awaiting request response"
assert len(summary.pending_request_info_events) == 1
pending_event = summary.pending_request_info_events[0]
assert isinstance(pending_event, WorkflowEvent)
assert pending_event.type == "request_info"
assert pending_event.request_id == "request-123"
assert pending_event.source_executor_id == "review_gateway"
assert pending_event.request_type is MockRequest
assert pending_event.response_type is bool
assert isinstance(pending_event.data, MockRequest)
async def test_request_info_event_serializes_non_json_payloads() -> None:
req_1 = WorkflowEvent.request_info(
request_id="req-1",
@@ -176,20 +98,260 @@ async def test_request_info_event_serializes_non_json_payloads() -> None:
await runner_context.add_request_info_event(req_1)
await runner_context.add_request_info_event(req_2)
checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1)
checkpoint_id = await runner_context.create_checkpoint("test_name", "test_hash", State(), None, iteration_count=1)
checkpoint = await runner_context.load_checkpoint(checkpoint_id)
# Should be JSON serializable despite datetime/slots
serialized = json.dumps(encode_checkpoint_value(checkpoint))
assert isinstance(serialized, str)
# Verify the structure contains pickled data for the request data fields
deserialized = json.loads(serialized)
assert _PICKLE_MARKER in deserialized # checkpoint itself is pickled
assert "value" in deserialized
deserialized = deserialized["value"]
# Verify we can rehydrate the checkpoint correctly
await runner_context.apply_checkpoint(checkpoint)
pending = await runner_context.get_pending_request_info_events()
assert "pending_request_info_events" in deserialized
pending_request_info_events = deserialized["pending_request_info_events"]
assert "req-1" in pending_request_info_events
assert isinstance(pending_request_info_events["req-1"]["data"]["value"]["issued_at"], str)
assert "req-1" in pending
rehydrated_1 = pending["req-1"]
assert isinstance(rehydrated_1.data, TimedApproval)
assert rehydrated_1.data.issued_at == datetime(2024, 5, 4, 12, 30, 45)
assert "req-2" in pending_request_info_events
assert pending_request_info_events["req-2"]["data"]["value"]["note"] == "slot-based"
assert "req-2" in pending
rehydrated_2 = pending["req-2"]
assert isinstance(rehydrated_2.data, SlottedApproval)
assert rehydrated_2.data.note == "slot-based"
async def test_checkpoint_with_pending_request_info_events():
"""Test that request info events are properly serialized in checkpoints and can be restored."""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir:
# Use file-based storage to test full serialization
storage = FileCheckpointStorage(temp_dir)
# Create workflow with checkpointing enabled
executor = ApprovalRequiredExecutor(id="approval_executor")
workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
# Step 1: Run workflow to completion to ensure checkpoints are created
request_info_event: WorkflowEvent | None = None
async for event in workflow.run("checkpoint test operation", stream=True):
if event.type == "request_info":
request_info_event = event
# Verify request was emitted
assert request_info_event is not None
assert isinstance(request_info_event.data, UserApprovalRequest)
assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation"
assert request_info_event.source_executor_id == "approval_executor"
# Step 2: List checkpoints to find the one with our pending request
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
assert len(checkpoints) > 0, "No checkpoints were created during workflow execution"
# Find the checkpoint with our pending request
checkpoint_with_request = None
for checkpoint in checkpoints:
if request_info_event.request_id in checkpoint.pending_request_info_events:
checkpoint_with_request = checkpoint
break
assert checkpoint_with_request is not None, "No checkpoint found with pending request info event"
# Step 3: Verify the pending request info event was properly serialized
serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id]
assert serialized_event.data
assert serialized_event.request_type is UserApprovalRequest
assert serialized_event.request_id == request_info_event.request_id
assert serialized_event.source_executor_id == "approval_executor"
# Step 4: Create a fresh workflow and restore from checkpoint
new_executor = ApprovalRequiredExecutor(id="approval_executor")
restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
# Step 5: Resume from checkpoint and verify the request can be continued
completed = False
restored_request_event: WorkflowEvent | None = None
async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True):
# Should re-emit the pending request info event
if event.type == "request_info" and event.request_id == request_info_event.request_id:
restored_request_event = event
elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS:
completed = True
assert completed, "Workflow should reach idle with pending requests state after restoration"
assert restored_request_event is not None, "Restored request info event should be emitted"
# Verify the restored event matches the original
assert restored_request_event.source_executor_id == request_info_event.source_executor_id
assert isinstance(restored_request_event.data, UserApprovalRequest)
assert restored_request_event.data.prompt == request_info_event.data.prompt
assert restored_request_event.data.context == request_info_event.data.context
# Step 6: Provide response to the restored request and complete the workflow
final_completed = False
async for event in restored_workflow.run(
stream=True,
responses={
request_info_event.request_id: True # Approve the request
},
):
if event.type == "status" and event.state == WorkflowRunState.IDLE:
final_completed = True
assert final_completed, "Workflow should complete after providing response to restored request"
# Step 7: Verify the executor state was properly restored and response was processed
assert new_executor.approval_received is True
expected_result = "Operation approved: Please approve the operation: checkpoint test operation"
assert new_executor.final_result == expected_result
async def test_checkpoint_restore_with_responses_does_not_reemit_handled_requests():
"""Test that request_info events are not re-emitted when responses are provided with checkpoint restore.
When calling run(checkpoint_id=..., responses=...), the workflow restores from a checkpoint
that contains pending request_info events. Because responses are provided for those events,
they should NOT be re-emitted in the event stream - they are considered "handled".
Note: The workflow's internal state tracking still sees the request_info events (before filtering),
so the final status may be IDLE_WITH_PENDING_REQUESTS even though the requests were handled.
The key behavior we're testing is that the CALLER doesn't see the request_info events.
"""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir:
# Use file-based storage to test full serialization
storage = FileCheckpointStorage(temp_dir)
# Create workflow with checkpointing enabled
executor = ApprovalRequiredExecutor(id="approval_executor")
workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
# Step 1: Run workflow until it emits a request_info event
request_info_event: WorkflowEvent | None = None
async for event in workflow.run("test pending request suppression", stream=True):
if event.type == "request_info":
request_info_event = event
assert request_info_event is not None
request_id = request_info_event.request_id
# Step 2: Find the checkpoint with the pending request
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
checkpoint_with_request = None
for checkpoint in checkpoints:
if request_id in checkpoint.pending_request_info_events:
checkpoint_with_request = checkpoint
break
assert checkpoint_with_request is not None
# Step 3: Create a fresh workflow and restore from checkpoint WITH responses in one call
new_executor = ApprovalRequiredExecutor(id="approval_executor")
restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
# Track all emitted events
emitted_events: list[WorkflowEvent] = []
async for event in restored_workflow.run(
checkpoint_id=checkpoint_with_request.checkpoint_id,
responses={request_id: True}, # Provide response for the pending request
stream=True,
):
emitted_events.append(event)
# Step 4: Verify the request_info event was NOT re-emitted to the caller
reemitted_request_info_events = [
e for e in emitted_events if e.type == "request_info" and e.request_id == request_id
]
assert len(reemitted_request_info_events) == 0, (
f"request_info event should NOT be re-emitted when response is provided. "
f"Found {len(reemitted_request_info_events)} request_info events with request_id={request_id}"
)
# Step 5: Verify the response was processed by checking executor state
assert new_executor.approval_received is True, "Response should have been processed by the executor"
assert new_executor.final_result == (
"Operation approved: Please approve the operation: test pending request suppression"
)
async def test_checkpoint_restore_with_partial_responses_reemits_unhandled_requests():
"""Test that only unhandled request_info events are re-emitted when partial responses are provided.
When calling run(checkpoint_id=..., responses=...) with responses for only some of the
pending requests, only the unhandled request_info events should be re-emitted.
"""
import tempfile
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
# Create workflow with multiple requests
executor = MultiRequestExecutor(id="multi_executor")
workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build()
# Step 1: Run workflow until it emits multiple request_info events
request_events: list[WorkflowEvent] = []
async for event in workflow.run("start batch", stream=True):
if event.type == "request_info":
request_events.append(event)
assert len(request_events) == 2
# Find the approval and calculation requests
approval_event = next((e for e in request_events if isinstance(e.data, UserApprovalRequest)), None)
calc_event = next((e for e in request_events if isinstance(e.data, CalculationRequest)), None)
assert approval_event is not None
assert calc_event is not None
# Step 2: Find the checkpoint with pending requests
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
checkpoint_with_requests = None
for checkpoint in checkpoints:
has_approval = approval_event.request_id in checkpoint.pending_request_info_events
has_calc = calc_event.request_id in checkpoint.pending_request_info_events
if has_approval and has_calc:
checkpoint_with_requests = checkpoint
break
assert checkpoint_with_requests is not None
# Step 3: Restore from checkpoint with ONLY the approval response (not the calculation)
new_executor = MultiRequestExecutor(id="multi_executor")
restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build()
emitted_events: list[WorkflowEvent] = []
async for event in restored_workflow.run(
checkpoint_id=checkpoint_with_requests.checkpoint_id,
responses={approval_event.request_id: True}, # Only respond to approval
stream=True,
):
emitted_events.append(event)
# Step 4: Verify the approval request_info was NOT re-emitted
reemitted_approval_events = [
e for e in emitted_events if e.type == "request_info" and e.request_id == approval_event.request_id
]
assert len(reemitted_approval_events) == 0, (
"Approval request_info should NOT be re-emitted since response was provided"
)
# Step 5: Verify the calculation request_info WAS re-emitted (no response provided)
reemitted_calc_events = [
e for e in emitted_events if e.type == "request_info" and e.request_id == calc_event.request_id
]
assert len(reemitted_calc_events) == 1, (
"Calculation request_info SHOULD be re-emitted since no response was provided"
)
# Step 6: Verify workflow is in IDLE_WITH_PENDING_REQUESTS state (calc still pending)
status_events = [e for e in emitted_events if e.type == "status"]
final_status = status_events[-1] if status_events else None
assert final_status is not None
assert final_status.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, (
f"Workflow should be IDLE_WITH_PENDING_REQUESTS, got {final_status.state}"
)
@@ -2,6 +2,7 @@
import asyncio
from dataclasses import dataclass
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -9,6 +10,9 @@ from agent_framework import (
AgentExecutorResponse,
AgentResponse,
Executor,
InMemoryCheckpointStorage,
WorkflowCheckpoint,
WorkflowCheckpointException,
WorkflowContext,
WorkflowConvergenceException,
WorkflowEvent,
@@ -16,6 +20,7 @@ from agent_framework import (
WorkflowRunState,
handler,
)
from agent_framework._workflows._const import EXECUTOR_STATE_KEY
from agent_framework._workflows._edge import SingleEdgeGroup
from agent_framework._workflows._runner import Runner
from agent_framework._workflows._runner_context import (
@@ -61,7 +66,14 @@ def test_create_runner():
executor_b.id: executor_b,
}
runner = Runner(edge_groups, executors, state=State(), ctx=InProcRunnerContext())
runner = Runner(
edge_groups,
executors,
state=State(),
ctx=InProcRunnerContext(),
workflow_name="test_name",
graph_signature_hash="test_hash",
)
assert runner.context is not None and isinstance(runner.context, RunnerContext)
@@ -84,7 +96,7 @@ async def test_runner_run_until_convergence():
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, state, ctx)
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
result: int | None = None
await executor_a.execute(
@@ -122,7 +134,7 @@ async def test_runner_run_until_convergence_not_completed():
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, state, ctx, max_iterations=5)
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash", max_iterations=5)
await executor_a.execute(
MockMessage(data=0),
@@ -156,7 +168,7 @@ async def test_runner_already_running():
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, state, ctx)
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
await executor_a.execute(
MockMessage(data=0),
@@ -176,7 +188,7 @@ async def test_runner_already_running():
async def test_runner_emits_runner_completion_for_agent_response_without_targets():
ctx = InProcRunnerContext()
runner = Runner([], {}, State(), ctx)
runner = Runner([], {}, State(), ctx, "test_name", graph_signature_hash="test_hash")
await ctx.send_message(
WorkflowMessage(
@@ -228,7 +240,7 @@ async def test_runner_cancellation_stops_active_executor():
shared_state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, shared_state, ctx)
runner = Runner(edges, executors, shared_state, ctx, "test_name", graph_signature_hash="test_hash")
await executor_a.execute(
MockMessage(data=0),
@@ -259,3 +271,579 @@ async def test_runner_cancellation_stops_active_executor():
assert executor_a.completed_count == 1
assert executor_b.started_count == 1
assert executor_b.completed_count == 0 # Should NOT have completed due to cancellation
class FailingExecutor(Executor):
"""An executor that fails during execution."""
def __init__(self, id: str, fail_on_data: int = 5):
super().__init__(id=id)
self.fail_on_data = fail_on_data
@handler
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
if message.data == self.fail_on_data:
raise RuntimeError("Simulated executor failure")
await ctx.send_message(MockMessage(data=message.data + 1))
async def test_runner_iteration_exception_drains_events():
"""Test that when an executor raises an exception, events are drained before propagating."""
executor_a = FailingExecutor(id="executor_a", fail_on_data=2)
executor_b = MockExecutor(id="executor_b")
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
await executor_a.execute(
MockMessage(data=0),
["START"],
state,
ctx,
)
events: list[WorkflowEvent] = []
with pytest.raises(RuntimeError, match="Simulated executor failure"):
async for event in runner.run_until_convergence():
events.append(event)
# There should be some events emitted before the failure
assert len(events) > 0
async def test_runner_reset_iteration_count():
"""Test that reset_iteration_count works correctly."""
executor_a = MockExecutor(id="executor_a")
state = State()
ctx = InProcRunnerContext()
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
runner._iteration = 10
runner.reset_iteration_count()
assert runner._iteration == 0
class CheckpointingContext(InProcRunnerContext):
"""A context that supports checkpointing for testing."""
def __init__(self, storage: InMemoryCheckpointStorage | None = None):
super().__init__()
self._storage = storage or InMemoryCheckpointStorage()
self._checkpointing_enabled = True
def has_checkpointing(self) -> bool:
return self._checkpointing_enabled
async def create_checkpoint(
self,
workflow_name: str,
graph_signature_hash: str,
state: State,
previous_checkpoint_id: str | None,
iteration: int,
) -> str:
checkpoint = WorkflowCheckpoint(
workflow_name=workflow_name,
graph_signature_hash=graph_signature_hash,
state=state.export(),
previous_checkpoint_id=previous_checkpoint_id,
iteration_count=iteration,
)
return await self._storage.save(checkpoint)
async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None:
try:
return await self._storage.load(checkpoint_id)
except WorkflowCheckpointException:
return None
async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None:
# Restore messages from checkpoint
for source_id, messages in checkpoint.messages.items():
for msg_data in messages:
await self.send_message(WorkflowMessage(data=msg_data, source_id=source_id))
class FailingCheckpointContext(InProcRunnerContext):
"""A context that fails during checkpoint creation."""
def has_checkpointing(self) -> bool:
return True
async def create_checkpoint(
self,
workflow_name: str,
graph_signature_hash: str,
state: State,
previous_checkpoint_id: str | None,
iteration: int,
) -> str:
raise RuntimeError("Simulated checkpoint failure")
async def test_runner_checkpoint_creation_failure():
"""Test that checkpoint creation failure is handled gracefully."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
state = State()
ctx = FailingCheckpointContext()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
await executor_a.execute(
MockMessage(data=0),
["START"],
state,
ctx,
)
# Should complete without raising, even though checkpointing fails
result: int | None = None
async for event in runner.run_until_convergence():
if event.type == "output":
result = event.data
assert result == 10
async def test_runner_restore_from_checkpoint_with_external_storage():
"""Test restoring from checkpoint using external storage when context has no checkpointing."""
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
state = State()
ctx = InProcRunnerContext() # No checkpointing enabled
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
# Create a checkpoint manually
storage = InMemoryCheckpointStorage()
checkpoint = WorkflowCheckpoint(
workflow_name="test_name",
graph_signature_hash="test_hash",
state={"test_key": "test_value"},
iteration_count=5,
)
checkpoint_id = await storage.save(checkpoint)
# Restore using external storage
await runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage=storage)
assert runner._resumed_from_checkpoint is True
assert runner._iteration == 5
assert state.get("test_key") == "test_value"
async def test_runner_restore_from_checkpoint_no_storage():
"""Test that restore fails when no checkpointing and no external storage."""
state = State()
ctx = InProcRunnerContext()
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowCheckpointException, match="Cannot load checkpoint"):
await runner.restore_from_checkpoint("nonexistent-id")
async def test_runner_restore_from_checkpoint_not_found():
"""Test that restore fails when checkpoint is not found."""
storage = InMemoryCheckpointStorage()
ctx = CheckpointingContext(storage)
state = State()
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowCheckpointException, match="not found"):
await runner.restore_from_checkpoint("nonexistent-id")
async def test_runner_restore_from_checkpoint_graph_hash_mismatch():
"""Test that restore fails when graph hash doesn't match."""
storage = InMemoryCheckpointStorage()
ctx = CheckpointingContext(storage)
state = State()
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="current_hash")
# Create a checkpoint with a different graph hash
checkpoint = WorkflowCheckpoint(
workflow_name="test_name",
graph_signature_hash="different_hash",
state={},
iteration_count=5,
)
checkpoint_id = await storage.save(checkpoint)
with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"):
await runner.restore_from_checkpoint(checkpoint_id)
async def test_runner_restore_from_checkpoint_generic_exception():
"""Test that generic exceptions during restore are wrapped in WorkflowCheckpointException."""
state = State()
# Create a mock context that raises a generic exception
mock_ctx = MagicMock(spec=InProcRunnerContext)
mock_ctx.has_checkpointing.return_value = True
mock_ctx.load_checkpoint = AsyncMock(side_effect=ValueError("Unexpected error"))
runner = Runner([], {}, state, mock_ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowCheckpointException, match="Failed to restore from checkpoint"):
await runner.restore_from_checkpoint("some-id")
async def test_runner_restore_executor_states_invalid_states_type():
"""Test that restore fails when executor states is not a dict."""
executor_a = MockExecutor(id="executor_a")
state = State()
state.set(EXECUTOR_STATE_KEY, "not_a_dict")
state.commit()
ctx = InProcRunnerContext()
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowCheckpointException, match="not a dictionary"):
await runner._restore_executor_states()
async def test_runner_restore_executor_states_invalid_executor_id_type():
"""Test that restore fails when executor ID is not a string."""
executor_a = MockExecutor(id="executor_a")
state = State()
state.set(EXECUTOR_STATE_KEY, {123: {"key": "value"}}) # Non-string key
state.commit()
ctx = InProcRunnerContext()
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowCheckpointException, match="not a string"):
await runner._restore_executor_states()
async def test_runner_restore_executor_states_invalid_state_type():
"""Test that restore fails when executor state is not a dict[str, Any]."""
executor_a = MockExecutor(id="executor_a")
state = State()
state.set(EXECUTOR_STATE_KEY, {"executor_a": "not_a_dict"})
state.commit()
ctx = InProcRunnerContext()
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowCheckpointException, match="not a dict"):
await runner._restore_executor_states()
async def test_runner_restore_executor_states_invalid_state_keys():
"""Test that restore fails when executor state dict has non-string keys."""
executor_a = MockExecutor(id="executor_a")
state = State()
state.set(EXECUTOR_STATE_KEY, {"executor_a": {123: "value"}}) # Non-string key in state
state.commit()
ctx = InProcRunnerContext()
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowCheckpointException, match="not a dict"):
await runner._restore_executor_states()
async def test_runner_restore_executor_states_missing_executor():
"""Test that restore fails when executor is not found."""
state = State()
state.set(EXECUTOR_STATE_KEY, {"missing_executor": {"key": "value"}})
state.commit()
ctx = InProcRunnerContext()
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowCheckpointException, match="not found during state restoration"):
await runner._restore_executor_states()
async def test_runner_set_executor_state_invalid_existing_states():
"""Test that _set_executor_state fails when existing states is not a dict."""
executor_a = MockExecutor(id="executor_a")
state = State()
state.set(EXECUTOR_STATE_KEY, "not_a_dict")
ctx = InProcRunnerContext()
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
with pytest.raises(WorkflowCheckpointException, match="not a dictionary"):
await runner._set_executor_state("executor_a", {"key": "value"})
async def test_runner_with_pre_loop_events():
"""Test that pre-loop events are yielded correctly."""
ctx = InProcRunnerContext()
state = State()
runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash")
# Add an event before running
await ctx.add_event(WorkflowEvent.output(executor_id="test_executor", data="pre-loop-output"))
events: list[WorkflowEvent] = []
async for event in runner.run_until_convergence():
events.append(event)
# Should have the pre-loop output event
output_events = [e for e in events if e.type == "output"]
assert len(output_events) == 1
assert output_events[0].data == "pre-loop-output"
class EventEmittingExecutor(Executor):
"""An executor that emits events during execution."""
@handler
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
# Emit event during processing
await ctx.yield_output(f"processed-{message.data}")
if message.data < 3:
await ctx.send_message(MockMessage(data=message.data + 1))
async def test_runner_drains_straggler_events():
"""Test that events emitted at the end of iteration are drained."""
executor_a = EventEmittingExecutor(id="executor_a")
executor_b = EventEmittingExecutor(id="executor_b")
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
await executor_a.execute(
MockMessage(data=0),
["START"],
state,
ctx,
)
events: list[WorkflowEvent] = []
async for event in runner.run_until_convergence():
events.append(event)
# Should have output events from both executors
output_events = [e for e in events if e.type == "output"]
assert len(output_events) > 0
async def test_runner_restore_executor_states_no_states():
"""Test that restore does nothing when there are no executor states."""
executor_a = MockExecutor(id="executor_a")
state = State() # No executor states set
state.commit()
ctx = InProcRunnerContext()
runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash")
# Should complete without error when no executor states exist
await runner._restore_executor_states()
async def test_runner_checkpoint_with_resumed_flag():
"""Test that resumed flag prevents initial checkpoint creation."""
storage = InMemoryCheckpointStorage()
ctx = CheckpointingContext(storage)
executor_a = MockExecutor(id="executor_a")
executor_b = MockExecutor(id="executor_b")
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
state = State()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
runner._mark_resumed(5)
# Add a message to trigger the checkpoint creation path
await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id="START"))
await executor_a.execute(
MockMessage(data=8),
["START"],
state,
ctx,
)
# Run until convergence
async for _ in runner.run_until_convergence():
pass
# After completing, resumed flag should be reset
assert runner._resumed_from_checkpoint is False
class ExecutorThatFailsWithEvents(Executor):
"""An executor that emits events and then raises an exception after receiving messages."""
def __init__(self, id: str, runner_ctx: RunnerContext, fail_on_iteration: int = 1):
super().__init__(id=id)
self._runner_ctx = runner_ctx
self._fail_on_iteration = fail_on_iteration
self._iteration_count = 0
@handler
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
self._iteration_count += 1
# First emit an output event to the workflow context
await ctx.yield_output(f"output-before-failure-{message.data}")
# Add some events directly to the runner context
await self._runner_ctx.add_event(WorkflowEvent.output(executor_id=self.id, data="pending-event"))
# Fail on the specified iteration
if self._iteration_count >= self._fail_on_iteration:
raise RuntimeError("Executor failed with pending events")
# Otherwise, send to next
await ctx.send_message(MockMessage(data=message.data + 1))
class PassthroughExecutor(Executor):
"""An executor that passes messages through to the failing executor."""
@handler
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
await ctx.send_message(MockMessage(data=message.data))
async def test_runner_drains_events_on_iteration_exception():
"""Test that events are drained when iteration task raises an exception (lines 128-129)."""
ctx = InProcRunnerContext()
# executor_b will fail with pending events after receiving a message
executor_a = PassthroughExecutor(id="executor_a")
executor_b = ExecutorThatFailsWithEvents(id="executor_b", runner_ctx=ctx, fail_on_iteration=1)
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
]
executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
state = State()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
# Execute through executor_a which will pass to executor_b during the runner iteration
await executor_a.execute(
MockMessage(data=0),
["START"],
state,
ctx,
)
events: list[WorkflowEvent] = []
with pytest.raises(RuntimeError, match="Executor failed with pending events"):
async for event in runner.run_until_convergence():
events.append(event)
# Events should include the ones emitted before the exception
output_events = [e for e in events if e.type == "output"]
# Should have drained the pending events before propagating the exception
assert len(output_events) >= 1
class SlowEventEmittingExecutor(Executor):
"""An executor that emits events with delays to test straggler event draining."""
def __init__(self, id: str, iterations_to_emit: int = 2):
super().__init__(id=id)
self.iterations_to_emit = iterations_to_emit
self.current_iteration = 0
@handler
async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None:
self.current_iteration += 1
# Emit output event
await ctx.yield_output(f"iteration-{self.current_iteration}")
# Continue sending messages until we reach the target iterations
if self.current_iteration < self.iterations_to_emit:
await ctx.send_message(MockMessage(data=message.data + 1))
async def test_runner_drains_straggler_events_at_iteration_end():
"""Test that events emitted at the very end of iteration are drained (lines 135-136)."""
# Create executors that ping-pong messages and emit events
executor_a = SlowEventEmittingExecutor(id="executor_a", iterations_to_emit=3)
executor_b = SlowEventEmittingExecutor(id="executor_b", iterations_to_emit=3)
edges = [
SingleEdgeGroup(executor_a.id, executor_b.id),
SingleEdgeGroup(executor_b.id, executor_a.id),
]
executors: dict[str, Executor] = {
executor_a.id: executor_a,
executor_b.id: executor_b,
}
state = State()
ctx = InProcRunnerContext()
runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash")
await executor_a.execute(
MockMessage(data=0),
["START"],
state,
ctx,
)
events: list[WorkflowEvent] = []
async for event in runner.run_until_convergence():
events.append(event)
# Check that output events were collected (including straggler events)
output_events = [e for e in events if e.type == "output"]
# We should have output events from both executors
assert len(output_events) >= 2
@@ -647,12 +647,11 @@ class TestSerializationWorkflowClasses:
# Test 2: Without name and description (defaults)
workflow2 = WorkflowBuilder(start_executor=SampleExecutor(id="e2")).build()
assert workflow2.name is None
assert workflow2.name is not None
assert workflow2.description is None
data2 = workflow2.to_dict()
assert "name" not in data2 # Should not include None values
assert "description" not in data2
assert "description" not in data2 # Should not include None values
# Test 3: With only name (no description)
workflow3 = WorkflowBuilder(name="Named Only", start_executor=SampleExecutor(id="e3")).build()
@@ -595,7 +595,7 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None:
assert first_request_id is not None
# Get checkpoint
checkpoints = await storage.list_checkpoints(workflow1.id)
checkpoints = await storage.list_checkpoints(workflow_name=workflow1.name)
checkpoint_id = max(checkpoints, key=lambda cp: cp.iteration_count).checkpoint_id
# Step 2: Resume workflow from checkpoint
@@ -335,12 +335,9 @@ async def test_workflow_run_stream_from_checkpoint_invalid_checkpoint(
)
# Attempt to run from non-existent checkpoint should fail
try:
with pytest.raises(WorkflowCheckpointException, match="No checkpoint found with ID nonexistent_checkpoint_id"):
async for _ in workflow.run(checkpoint_id="nonexistent_checkpoint_id", stream=True):
pass
raise AssertionError("Expected WorkflowCheckpointException to be raised")
except WorkflowCheckpointException as e:
assert str(e) == "Checkpoint nonexistent_checkpoint_id not found"
async def test_workflow_run_stream_from_checkpoint_with_external_storage(
@@ -354,12 +351,14 @@ async def test_workflow_run_stream_from_checkpoint_with_external_storage(
from agent_framework import WorkflowCheckpoint
test_checkpoint = WorkflowCheckpoint(
workflow_id="test-workflow",
workflow_name="test-workflow",
graph_signature_hash="test-graph-signature",
previous_checkpoint_id=None,
messages={},
state={},
iteration_count=0,
)
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
checkpoint_id = await storage.save(test_checkpoint)
# Create a workflow WITHOUT checkpointing
workflow_without_checkpointing = (
@@ -385,17 +384,6 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
# Create a test checkpoint manually in storage
from agent_framework import WorkflowCheckpoint
test_checkpoint = WorkflowCheckpoint(
workflow_id="test-workflow",
messages={},
state={},
iteration_count=0,
)
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
# Build workflow with checkpointing
workflow = (
WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage)
@@ -403,6 +391,19 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu
.build()
)
# Create a test checkpoint manually in storage
from agent_framework import WorkflowCheckpoint
test_checkpoint = WorkflowCheckpoint(
workflow_name=workflow.name,
graph_signature_hash=workflow.graph_signature_hash,
previous_checkpoint_id=None,
messages={},
state={},
iteration_count=0,
)
checkpoint_id = await storage.save(test_checkpoint)
# Test non-streaming run method with checkpoint_id
result = await workflow.run(checkpoint_id=checkpoint_id)
assert isinstance(result, list) # Should return WorkflowRunResult which extends list
@@ -416,11 +417,19 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
with tempfile.TemporaryDirectory() as temp_dir:
storage = FileCheckpointStorage(temp_dir)
# Build workflow with checkpointing
workflow = (
WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage)
.add_edge(simple_executor, simple_executor)
.build()
)
# Create a test checkpoint manually in storage
from agent_framework import WorkflowCheckpoint
test_checkpoint = WorkflowCheckpoint(
workflow_id="test-workflow",
workflow_name=workflow.name,
graph_signature_hash=workflow.graph_signature_hash,
messages={},
state={},
pending_request_info_events={
@@ -429,18 +438,11 @@ async def test_workflow_run_stream_from_checkpoint_with_responses(
source_executor_id=simple_executor.id,
request_data="Mock",
response_type=str,
).to_dict(),
),
},
iteration_count=0,
)
checkpoint_id = await storage.save_checkpoint(test_checkpoint)
# Build workflow with checkpointing
workflow = (
WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage)
.add_edge(simple_executor, simple_executor)
.build()
)
checkpoint_id = await storage.save(test_checkpoint)
# Resume from checkpoint - pending request events should be emitted
events: list[WorkflowEvent] = []
@@ -542,7 +544,7 @@ async def test_workflow_checkpoint_runtime_only_configuration(
assert result.get_final_state() == WorkflowRunState.IDLE
# Verify checkpoints were created
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
assert len(checkpoints) > 0
# Find a superstep checkpoint to resume from
@@ -592,8 +594,8 @@ async def test_workflow_checkpoint_runtime_overrides_buildtime(
assert result is not None
# Verify checkpoints were created in runtime storage, not build-time storage
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=workflow.name)
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=workflow.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -607,7 +607,7 @@ class TestWorkflowAgent:
# Drain workflow events to get checkpoint
# The workflow should have created checkpoints
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
assert len(checkpoints) > 0, "Checkpoints should have been created when checkpoint_storage is provided"
async def test_agent_executor_output_response_false_filters_streaming_events(self):
@@ -306,8 +306,8 @@ async def test_end_to_end_workflow_tracing(span_exporter: InMemorySpanExporter)
assert len(build_spans_with_metadata) == 1
metadata_build_span = build_spans_with_metadata[0]
assert metadata_build_span.attributes is not None
assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_NAME) == "Test Pipeline"
assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_DESCRIPTION) == "Test workflow description"
assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_BUILDER_NAME) == "Test Pipeline"
assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_BUILDER_DESCRIPTION) == "Test workflow description"
# Clear spans to separate build from run tracing
span_exporter.clear()
@@ -451,14 +451,14 @@ async def test_message_trace_context_serialization(span_exporter: InMemorySpanEx
await ctx.send_message(message)
# Create a checkpoint that includes the message
checkpoint_id = await ctx.create_checkpoint(State(), 0)
checkpoint_id = await ctx.create_checkpoint("test_name", "test_hash", State(), None, 0)
checkpoint = await ctx.load_checkpoint(checkpoint_id)
assert checkpoint is not None
# Check serialized message includes trace context
serialized_msg = checkpoint.messages["source"][0]
assert serialized_msg["trace_contexts"] == [{"traceparent": "00-trace-span-01"}]
assert serialized_msg["source_span_ids"] == ["span123"]
assert serialized_msg.trace_contexts == [{"traceparent": "00-trace-span-01"}]
assert serialized_msg.source_span_ids == ["span123"]
# Test deserialization
await ctx.apply_checkpoint(checkpoint)
@@ -430,7 +430,7 @@ class AgentFrameworkExecutor:
elif hil_responses:
# Only auto-resume from latest checkpoint when we have HIL responses
# Regular "Run" clicks should start fresh, not resume from checkpoints
checkpoints = await checkpoint_storage.list_checkpoints() # No workflow_id filter needed!
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
if checkpoints:
latest = max(checkpoints, key=lambda cp: cp.timestamp)
checkpoint_id = latest.checkpoint_id
@@ -1059,7 +1059,7 @@ class DevServer:
# Extract checkpoint_id from item_id (format: "checkpoint_{checkpoint_id}")
checkpoint_id = item_id[len("checkpoint_") :]
storage = executor.checkpoint_manager.get_checkpoint_storage(conversation_id)
deleted = await storage.delete_checkpoint(checkpoint_id)
deleted = await storage.delete(checkpoint_id)
if not deleted:
raise HTTPException(status_code=404, detail="Checkpoint not found")
@@ -32,7 +32,6 @@ from agent_framework import Agent, AgentThread, Message, SupportsAgentRun
from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse
from agent_framework._workflows._agent_utils import resolve_agent_id
from agent_framework._workflows._checkpoint import CheckpointStorage
from agent_framework._workflows._conversation_state import decode_chat_messages, encode_chat_messages
from agent_framework._workflows._executor import Executor
from agent_framework._workflows._workflow import Workflow
from agent_framework._workflows._workflow_builder import WorkflowBuilder
@@ -476,7 +475,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator):
async def on_checkpoint_save(self) -> dict[str, Any]:
"""Capture current orchestrator state for checkpointing."""
state = await super().on_checkpoint_save()
state["cache"] = encode_chat_messages(self._cache)
state["cache"] = self._cache
serialized_thread = await self._thread.serialize()
state["thread"] = serialized_thread
@@ -486,7 +485,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator):
async def on_checkpoint_restore(self, state: dict[str, Any]) -> None:
"""Restore executor state from checkpoint."""
await super().on_checkpoint_restore(state)
self._cache = decode_chat_messages(state.get("cache", []))
self._cache = state.get("cache", [])
serialized_thread = state.get("thread")
if serialized_thread:
self._thread = await self._agent.deserialize_thread(serialized_thread)
@@ -59,15 +59,14 @@ class OrchestrationState:
Returns:
Dict with encoded conversation and metadata for persistence
"""
from agent_framework._workflows._conversation_state import encode_chat_messages
result: dict[str, Any] = {
"conversation": encode_chat_messages(self.conversation),
"conversation": self.conversation,
"round_index": self.round_index,
"orchestrator_name": self.orchestrator_name,
"metadata": dict(self.metadata),
}
if self.task is not None:
result["task"] = encode_chat_messages([self.task])[0]
result["task"] = self.task
return result
@classmethod
@@ -80,16 +79,15 @@ class OrchestrationState:
Returns:
Restored OrchestrationState instance
"""
from agent_framework._workflows._conversation_state import decode_chat_messages
task = None
if "task" in data:
decoded_tasks = decode_chat_messages([data["task"]])
decoded_tasks = [data["task"]]
task = decoded_tasks[0] if decoded_tasks else None
return cls(
conversation=decode_chat_messages(data.get("conversation", [])),
conversation=data.get("conversation", []),
round_index=data.get("round_index", 0),
orchestrator_name=data.get("orchestrator_name", ""),
metadata=dict(data.get("metadata", {})),
task=task,
)
@@ -224,13 +224,10 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None:
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
)
resume_checkpoint = checkpoints[1]
resumed_participants = (
_FakeAgentExec("agentA", "Alpha"),
@@ -270,14 +267,13 @@ async def test_concurrent_checkpoint_runtime_only() -> None:
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) >= 2, (
"Expected at least 2 checkpoints. The first one is after the start executor, "
"and the second one is after the first round of agent executions."
)
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[1]
resumed_agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")]
wf_resume = ConcurrentBuilder(participants=resumed_agents).build()
@@ -320,8 +316,8 @@ async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None:
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -620,7 +620,7 @@ async def test_group_chat_checkpoint_runtime_only() -> None:
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints"
@@ -656,8 +656,8 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None:
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -362,7 +362,7 @@ async def test_magentic_checkpoint_resume_round_trip():
assert req_event is not None
assert isinstance(req_event.data, MagenticPlanReviewRequest)
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[-1]
@@ -605,8 +605,9 @@ async def test_agent_executor_invoke_with_assistants_client_messages():
async def _collect_checkpoints(
storage: InMemoryCheckpointStorage,
workflow_name: str,
) -> list[WorkflowCheckpoint]:
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=workflow_name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
return checkpoints
@@ -619,12 +620,13 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep():
participants=[StubThreadAgent()], checkpoint_storage=storage, manager=InvokeOnceManager()
).build()
async for event in workflow.run("inner-loop task", stream=True):
if event.type == "output":
break
async for _ in workflow.run("inner-loop task", stream=True):
continue
checkpoints = await _collect_checkpoints(storage)
inner_loop_checkpoint = next(cp for cp in checkpoints if cp.metadata.get("superstep") == 1) # type: ignore[reportUnknownMemberType]
checkpoints = await _collect_checkpoints(storage, workflow.name)
# The first checkpoint is after the manager has run.
# The second checkpoint is after the participant has run.
inner_loop_checkpoint = checkpoints[1]
resumed = MagenticBuilder(
participants=[StubThreadAgent()], checkpoint_storage=storage, manager=InvokeOnceManager()
@@ -651,7 +653,7 @@ async def test_magentic_checkpoint_resume_from_saved_state():
if event.type == "output":
break
checkpoints = await _collect_checkpoints(storage)
checkpoints = await _collect_checkpoints(storage, workflow.name)
# Verify we can resume from the last saved checkpoint
resumed_state = checkpoints[-1] # Use the last checkpoint
@@ -688,7 +690,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames():
assert req_event is not None
assert isinstance(req_event.data, MagenticPlanReviewRequest)
checkpoints = await _collect_checkpoints(storage)
checkpoints = await _collect_checkpoints(storage, workflow.name)
target_checkpoint = checkpoints[-1]
renamed_workflow = MagenticBuilder(
@@ -772,7 +774,7 @@ async def test_magentic_checkpoint_runtime_only() -> None:
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints"
@@ -806,8 +808,8 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None:
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -856,13 +858,13 @@ async def test_magentic_checkpoint_restore_no_duplicate_history():
break
# Get checkpoint
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert len(checkpoints) > 0, "Should have created checkpoints"
latest_checkpoint = checkpoints[-1]
# Load checkpoint and verify no duplicates in state
checkpoint_data = await storage.load_checkpoint(latest_checkpoint.checkpoint_id)
checkpoint_data = await storage.load(latest_checkpoint.checkpoint_id)
assert checkpoint_data is not None
# Check the magentic_context in the checkpoint
@@ -146,14 +146,10 @@ async def test_sequential_checkpoint_resume_round_trip() -> None:
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
)
resume_checkpoint = checkpoints[0]
resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf_resume = SequentialBuilder(participants=list(resumed_agents), checkpoint_storage=storage).build()
@@ -189,14 +185,10 @@ async def test_sequential_checkpoint_runtime_only() -> None:
assert baseline_output is not None
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=wf.name)
assert checkpoints
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = next(
(cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"),
checkpoints[-1],
)
resume_checkpoint = checkpoints[0]
resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2"))
wf_resume = SequentialBuilder(participants=list(resumed_agents)).build()
@@ -240,8 +232,8 @@ async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None:
assert baseline_output is not None
buildtime_checkpoints = await buildtime_storage.list_checkpoints()
runtime_checkpoints = await runtime_storage.list_checkpoints()
buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name)
runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name)
assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints"
assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden"
@@ -0,0 +1,230 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
from pathlib import Path
from typing import Any
from agent_framework import (
Agent,
Content,
FileCheckpointStorage,
Workflow,
tool,
)
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
from azure.identity import AzureCliCredential
"""
Sample: Handoff Workflow with Tool Approvals + Checkpoint Resume
Demonstrates resuming a handoff workflow from a checkpoint while handling both
HandoffAgentUserRequest prompts and function approval request Content for tool calls
(e.g., submit_refund).
Scenario:
1. User starts a conversation with the workflow.
2. Agents may emit user input requests or tool approval requests.
3. Workflow writes a checkpoint capturing pending requests and pauses.
4. Process can exit/restart.
5. On resume: Restore checkpoint, inspect pending requests, then provide responses.
6. Workflow continues from the saved state.
Pattern:
- workflow.run(checkpoint_id=..., stream=True) to restore checkpoint and discover pending requests.
- workflow.run(stream=True, responses=responses) to supply human replies and approvals.
(Two steps are needed here because the sample must inspect request types before building responses.
When response payloads are already known, use the single-call form:
workflow.run(stream=True, checkpoint_id=..., responses=responses).)
Prerequisites:
- Azure CLI authentication (az login).
- Environment variables configured for AzureOpenAIChatClient.
"""
CHECKPOINT_DIR = Path(__file__).parent / "tmp" / "handoff_checkpoints"
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
@tool(approval_mode="always_require")
def submit_refund(refund_description: str, amount: str, order_id: str) -> str:
"""Capture a refund request for manual review before processing."""
return f"refund recorded for order {order_id} (amount: {amount}) with details: {refund_description}"
def create_agents(client: AzureOpenAIChatClient) -> tuple[Agent, Agent, Agent]:
"""Create a simple handoff scenario: triage, refund, and order specialists."""
triage = client.as_agent(
name="triage_agent",
instructions=(
"You are a customer service triage agent. Listen to customer issues and determine "
"if they need refund help or order tracking. Use handoff_to_refund_agent or "
"handoff_to_order_agent to transfer them."
),
)
refund = client.as_agent(
name="refund_agent",
instructions=(
"You are a refund specialist. Help customers with refund requests. "
"Be empathetic and ask for order numbers if not provided. "
"When the user confirms they want a refund and supplies order details, call submit_refund "
"to record the request before continuing."
),
tools=[submit_refund],
)
order = client.as_agent(
name="order_agent",
instructions=(
"You are an order tracking specialist. Help customers track their orders. "
"Ask for order numbers and provide shipping updates."
),
)
return triage, refund, order
def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
"""Build the handoff workflow with checkpointing enabled."""
client = AzureOpenAIChatClient(credential=AzureCliCredential())
triage, refund, order = create_agents(client)
# checkpoint_storage: Enable checkpointing for resume
# termination_condition: Terminate after 5 user messages for this demo
return (
HandoffBuilder(
name="checkpoint_handoff_demo",
participants=[triage, refund, order],
checkpoint_storage=checkpoint_storage,
termination_condition=lambda conv: sum(1 for msg in conv if msg.role == "user") >= 5,
)
.with_start_agent(triage)
.build()
)
def print_handoff_agent_user_request(request: HandoffAgentUserRequest, request_id: str) -> None:
"""Log pending handoff request details for debugging."""
print(f"\n{'=' * 60}")
print("User input needed")
print(f"Request ID: {request_id}")
print(f"Awaiting agent: {request.agent_response.agent_id}")
response = request.agent_response
if not response.messages:
print("(No agent messages)")
return
for message in response.messages:
if not message.text:
continue
speaker = message.author_name or message.role
print(f"{speaker}: {message.text}")
print(f"{'=' * 60}\n")
def print_function_approval_request(request: Content, request_id: str) -> None:
"""Log pending tool approval details for debugging."""
args = request.function_call.parse_arguments() or {} # type: ignore
print(f"\n{'=' * 60}")
print("Tool approval required")
print(f"Request ID: {request_id}")
print(f"Function: {request.function_call.name}") # type: ignore
print(f"Arguments:\n{json.dumps(args, indent=2)}")
print(f"{'=' * 60}\n")
async def main() -> None:
"""
Demonstrate the checkpoint-based pause/resume pattern for handoff workflows.
This sample shows:
1. Starting a workflow and getting a HandoffAgentUserRequest
2. Pausing (checkpoint is saved automatically)
3. Resuming from checkpoint with a user response or tool approval
4. Continuing the conversation until completion
"""
# Clean up old checkpoints
for file in CHECKPOINT_DIR.glob("*.json"):
file.unlink()
for file in CHECKPOINT_DIR.glob("*.json.tmp"):
file.unlink()
storage = FileCheckpointStorage(storage_path=CHECKPOINT_DIR)
workflow = create_workflow(checkpoint_storage=storage)
# Scripted human input for demo purposes
handoff_responses = [
(
"The headphones in order 12345 arrived cracked. "
"Please submit the refund for $89.99 and send a replacement to my original address."
),
"Yes, that covers the damage and refund request.",
"That's everything I needed for the refund.",
"Thanks for handling the refund.",
]
print("=" * 60)
print("HANDOFF WORKFLOW CHECKPOINT DEMO")
print("=" * 60)
# Scenario: User needs help with a damaged order
initial_request = "Hi, my order 12345 arrived damaged. I need a refund."
# Phase 1: Initial run - workflow will pause when it needs user input
results = await workflow.run(message=initial_request)
request_events = results.get_request_info_events()
if not request_events:
print("Workflow completed without needing user input")
return
print("=" * 60)
print("WORKFLOW PAUSED with pending requests")
print("=" * 60)
# Phase 2: Running until no more user input is needed
# This creates a new workflow instance to simulate a fresh process start,
# but points it to the same checkpoint storage
while request_events:
print("=" * 60)
print("Simulating process restart...")
print("=" * 60)
workflow = create_workflow(checkpoint_storage=storage)
responses: dict[str, Any] = {}
for request_event in request_events:
print(f"Pending request ID: {request_event.request_id}, Type: {type(request_event.data)}")
if isinstance(request_event.data, HandoffAgentUserRequest):
print_handoff_agent_user_request(request_event.data, request_event.request_id)
response = handoff_responses.pop(0)
print(f"Responding with: {response}")
responses[request_event.request_id] = HandoffAgentUserRequest.create_response(response)
elif isinstance(request_event.data, Content) and request_event.data.type == "function_approval_request":
print_function_approval_request(request_event.data, request_event.request_id)
print("Approving tool call...")
responses[request_event.request_id] = request_event.data.to_function_approval_response(approved=True)
else:
# This sample only expects HandoffAgentUserRequest and function approval requests
raise ValueError(f"Unsupported request type: {type(request_event.data)}")
checkpoint = await storage.get_latest(workflow_name=workflow.name)
if not checkpoint:
raise RuntimeError("No checkpoints found.")
checkpoint_id = checkpoint.checkpoint_id
results = await workflow.run(responses=responses, checkpoint_id=checkpoint_id)
request_events = results.get_request_info_events()
print("\n" + "=" * 60)
print("DEMO COMPLETE")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
@@ -2,6 +2,7 @@
import asyncio
import json
from datetime import datetime
from pathlib import Path
from typing import cast
@@ -115,15 +116,11 @@ async def main() -> None:
print("No plan review request emitted; nothing to resume.")
return
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
if not checkpoints:
resume_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow.name)
if not resume_checkpoint:
print("No checkpoints persisted.")
return
resume_checkpoint = max(
checkpoints,
key=lambda cp: (cp.iteration_count, cp.timestamp),
)
print(f"Using checkpoint {resume_checkpoint.checkpoint_id} at iteration {resume_checkpoint.iteration_count}")
# Show that the checkpoint JSON indeed contains the pending plan-review request record.
@@ -180,7 +177,7 @@ async def main() -> None:
def _pending_message_count(cp: WorkflowCheckpoint) -> int:
return sum(len(msg_list) for msg_list in cp.messages.values() if isinstance(msg_list, list))
all_checkpoints = await checkpoint_storage.list_checkpoints(resume_checkpoint.workflow_id)
all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=resume_checkpoint.workflow_name)
later_checkpoints_with_messages = [
cp
for cp in all_checkpoints
@@ -188,10 +185,7 @@ async def main() -> None:
]
if later_checkpoints_with_messages:
post_plan_checkpoint = max(
later_checkpoints_with_messages,
key=lambda cp: (cp.iteration_count, cp.timestamp),
)
post_plan_checkpoint = max(later_checkpoints_with_messages, key=lambda cp: datetime.fromisoformat(cp.timestamp))
else:
later_checkpoints = [cp for cp in all_checkpoints if cp.iteration_count > resume_checkpoint.iteration_count]
@@ -199,10 +193,7 @@ async def main() -> None:
print("\nNo additional checkpoints recorded beyond plan approval; sample complete.")
return
post_plan_checkpoint = max(
later_checkpoints,
key=lambda cp: (cp.iteration_count, cp.timestamp),
)
post_plan_checkpoint = max(later_checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
print("\n=== Stage 3: resume from post-plan checkpoint ===")
pending_messages = _pending_message_count(post_plan_checkpoint)
print(
@@ -3,6 +3,7 @@
import asyncio
import sys
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -25,9 +26,7 @@ from agent_framework import (
Message,
Workflow,
WorkflowBuilder,
WorkflowCheckpoint,
WorkflowContext,
get_checkpoint_summary,
handler,
response_handler,
)
@@ -188,9 +187,7 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
prepare_brief = BriefPreparer(id="prepare_brief", agent_id="writer")
workflow_builder = (
WorkflowBuilder(
max_iterations=6, start_executor=prepare_brief, checkpoint_storage=checkpoint_storage
)
WorkflowBuilder(max_iterations=6, start_executor=prepare_brief, checkpoint_storage=checkpoint_storage)
.add_edge(prepare_brief, writer)
.add_edge(writer, review_gateway)
.add_edge(review_gateway, writer) # revisions loop
@@ -199,24 +196,6 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow:
return workflow_builder.build()
def render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None:
"""Pretty-print saved checkpoints with the new framework summaries."""
print("\nCheckpoint summary:")
for summary in [get_checkpoint_summary(cp) for cp in sorted(checkpoints, key=lambda c: c.timestamp)]:
# Compose a single line per checkpoint so the user can scan the output
# and pick the resume point that still has outstanding human work.
line = (
f"- {summary.checkpoint_id} | timestamp={summary.timestamp} | iter={summary.iteration_count} "
f"| targets={summary.targets} | states={summary.executor_ids}"
)
if summary.status:
line += f" | status={summary.status}"
if summary.pending_request_info_events:
line += f" | pending_request_id={summary.pending_request_info_events[0].request_id}"
print(line)
def prompt_for_responses(requests: dict[str, HumanApprovalRequest]) -> dict[str, str]:
"""Interactive CLI prompt for any live RequestInfo requests."""
@@ -304,16 +283,12 @@ async def main() -> None:
result = await run_interactive_session(workflow, initial_message=brief)
print(f"Workflow completed with: {result}")
checkpoints = await storage.list_checkpoints()
checkpoints = await storage.list_checkpoints(workflow_name=workflow.name)
if not checkpoints:
print("No checkpoints recorded.")
return
# Show the user what is available before we prompt for the index. The
# summary helper keeps this output consistent with other tooling.
render_checkpoint_summary(checkpoints)
sorted_cps = sorted(checkpoints, key=lambda c: c.timestamp)
sorted_cps = sorted(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp))
print("\nAvailable checkpoints:")
for idx, cp in enumerate(sorted_cps):
print(f" [{idx}] id={cp.checkpoint_id} iter={cp.iteration_count}")
@@ -337,10 +312,6 @@ async def main() -> None:
return
chosen = sorted_cps[idx]
summary = get_checkpoint_summary(chosen)
if summary.status == "completed":
print("Selected checkpoint already reflects a completed workflow; nothing to resume.")
return
new_workflow = create_workflow(checkpoint_storage=storage)
# Resume with a fresh workflow instance. The checkpoint carries the
@@ -140,10 +140,9 @@ async def main():
break
# Find the latest checkpoint to resume from
all_checkpoints = await checkpoint_storage.list_checkpoints()
if not all_checkpoints:
latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow.name)
if not latest_checkpoint:
raise RuntimeError("No checkpoints available to resume from.")
latest_checkpoint = all_checkpoints[-1]
print(
f"Checkpoint {latest_checkpoint.checkpoint_id}: "
f"(iter={latest_checkpoint.iteration_count}, messages={latest_checkpoint.messages})"
@@ -1,405 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
import asyncio
import json
import logging
from pathlib import Path
from typing import cast
from agent_framework import (
Agent,
AgentResponse,
Content,
FileCheckpointStorage,
Message,
Workflow,
WorkflowEvent,
tool,
)
from agent_framework.azure import AzureOpenAIChatClient
from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder
from azure.identity import AzureCliCredential
"""
Sample: Handoff Workflow with Tool Approvals + Checkpoint Resume
Demonstrates resuming a handoff workflow from a checkpoint while handling both
HandoffAgentUserRequest prompts and function approval request Content for tool calls
(e.g., submit_refund).
Scenario:
1. User starts a conversation with the workflow.
2. Agents may emit user input requests or tool approval requests.
3. Workflow writes a checkpoint capturing pending requests and pauses.
4. Process can exit/restart.
5. On resume: Restore checkpoint, inspect pending requests, then provide responses.
6. Workflow continues from the saved state.
Pattern:
- workflow.run(checkpoint_id=..., stream=True) to restore checkpoint and discover pending requests.
- workflow.run(stream=True, responses=responses) to supply human replies and approvals.
(Two steps are needed here because the sample must inspect request types before building responses.
When response payloads are already known, use the single-call form:
workflow.run(stream=True, checkpoint_id=..., responses=responses).)
Prerequisites:
- Azure CLI authentication (az login).
- Environment variables configured for AzureOpenAIChatClient.
"""
CHECKPOINT_DIR = Path(__file__).parent / "tmp" / "handoff_checkpoints"
CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True)
@tool(approval_mode="always_require")
def submit_refund(refund_description: str, amount: str, order_id: str) -> str:
"""Capture a refund request for manual review before processing."""
return f"refund recorded for order {order_id} (amount: {amount}) with details: {refund_description}"
def create_agents(client: AzureOpenAIChatClient) -> tuple[Agent, Agent, Agent]:
"""Create a simple handoff scenario: triage, refund, and order specialists."""
triage = client.as_agent(
name="triage_agent",
instructions=(
"You are a customer service triage agent. Listen to customer issues and determine "
"if they need refund help or order tracking. Use handoff_to_refund_agent or "
"handoff_to_order_agent to transfer them."
),
)
refund = client.as_agent(
name="refund_agent",
instructions=(
"You are a refund specialist. Help customers with refund requests. "
"Be empathetic and ask for order numbers if not provided. "
"When the user confirms they want a refund and supplies order details, call submit_refund "
"to record the request before continuing."
),
tools=[submit_refund],
)
order = client.as_agent(
name="order_agent",
instructions=(
"You are an order tracking specialist. Help customers track their orders. "
"Ask for order numbers and provide shipping updates."
),
)
return triage, refund, order
def create_workflow(checkpoint_storage: FileCheckpointStorage) -> tuple[Workflow, Agent, Agent, Agent]:
"""Build the handoff workflow with checkpointing enabled."""
client = AzureOpenAIChatClient(credential=AzureCliCredential())
triage, refund, order = create_agents(client)
# checkpoint_storage: Enable checkpointing for resume
# termination_condition: Terminate after 5 user messages for this demo
workflow = (
HandoffBuilder(
name="checkpoint_handoff_demo",
participants=[triage, refund, order],
checkpoint_storage=checkpoint_storage,
termination_condition=lambda conv: sum(1 for msg in conv if msg.role == "user") >= 5,
)
.with_start_agent(triage)
.build()
)
return workflow, triage, refund, order
def _print_handoff_agent_user_request(response: AgentResponse) -> None:
"""Display the agent's response messages when requesting user input."""
if not response.messages:
print("(No agent messages)")
return
print("\n[Agent is requesting your input...]")
for message in response.messages:
if not message.text:
continue
speaker = message.author_name or message.role
print(f" {speaker}: {message.text}")
def _print_handoff_request(request: HandoffAgentUserRequest, request_id: str) -> None:
"""Log pending handoff request details for debugging."""
print(f"\n{'=' * 60}")
print("WORKFLOW PAUSED - User input needed")
print(f"Request ID: {request_id}")
print(f"Awaiting agent: {request.agent_response.agent_id}")
_print_handoff_agent_user_request(request.agent_response)
print(f"{'=' * 60}\n")
def _print_function_approval_request(request: Content, request_id: str) -> None:
"""Log pending tool approval details for debugging."""
args = request.function_call.parse_arguments() or {} # type: ignore
print(f"\n{'=' * 60}")
print("WORKFLOW PAUSED - Tool approval required")
print(f"Request ID: {request_id}")
print(f"Function: {request.function_call.name}") # type: ignore
print(f"Arguments:\n{json.dumps(args, indent=2)}")
print(f"{'=' * 60}\n")
def _build_responses_for_requests(
pending_requests: list[WorkflowEvent],
*,
user_response: str | None,
approve_tools: bool | None,
) -> dict[str, object]:
"""Create response payloads for each pending request."""
responses: dict[str, object] = {}
for request in pending_requests:
if isinstance(request.data, HandoffAgentUserRequest) and request.request_id:
if user_response is None:
raise ValueError("User response is required for HandoffAgentUserRequest")
responses[request.request_id] = user_response
elif (
isinstance(request.data, Content)
and request.data.type == "function_approval_request"
and request.request_id
):
if approve_tools is None:
raise ValueError("Approval decision is required for function approval request")
responses[request.request_id] = request.data.to_function_approval_response(approved=approve_tools)
else:
raise ValueError(f"Unsupported request type: {type(request.data)}")
return responses
async def run_until_user_input_needed(
workflow: Workflow,
initial_message: str | None = None,
checkpoint_id: str | None = None,
) -> tuple[list[WorkflowEvent], str | None]:
"""
Run the workflow until it needs user input or approval, or completes.
Returns:
Tuple of (pending_requests, checkpoint_id_to_use_for_resume)
"""
pending_requests: list[WorkflowEvent] = []
latest_checkpoint_id: str | None = checkpoint_id
if initial_message:
print(f"\nStarting workflow with: {initial_message}\n")
event_stream = workflow.run(message=initial_message, stream=True) # type: ignore[attr-defined]
elif checkpoint_id:
print(f"\nResuming workflow from checkpoint: {checkpoint_id}\n")
event_stream = workflow.run(checkpoint_id=checkpoint_id, stream=True) # type: ignore[attr-defined]
else:
raise ValueError("Must provide either initial_message or checkpoint_id")
async for event in event_stream:
if event.type == "status":
print(f"[Status] {event.state}")
elif event.type == "request_info":
pending_requests.append(event)
if isinstance(event.data, HandoffAgentUserRequest):
_print_handoff_request(event.data, event.request_id)
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
_print_function_approval_request(event.data, event.request_id)
elif event.type == "output":
print("\n[Workflow Completed]")
if event.data:
print(f"Final conversation length: {len(event.data)} messages")
return [], None
# Workflow paused with pending requests
# The latest checkpoint was created at the end of the last superstep
# We'll use the checkpoint storage to find it
return pending_requests, latest_checkpoint_id
async def resume_with_responses(
workflow: Workflow,
checkpoint_storage: FileCheckpointStorage,
user_response: str | None = None,
approve_tools: bool | None = None,
) -> tuple[list[WorkflowEvent], str | None]:
"""
Resume from checkpoint and send responses.
Step 1: Restore checkpoint to discover pending request types.
Step 2: Build typed responses and send via workflow.run(responses=...).
When response payloads are already known, these can be combined into a single
workflow.run(stream=True, checkpoint_id=..., responses=...) call.
"""
print(f"\n{'=' * 60}")
print("RESUMING WORKFLOW WITH HUMAN INPUT")
if user_response is not None:
print(f"User says: {user_response}")
if approve_tools is not None:
print(f"Approve tools: {approve_tools}")
print(f"{'=' * 60}\n")
# Get the latest checkpoint
checkpoints = await checkpoint_storage.list_checkpoints()
if not checkpoints:
raise RuntimeError("No checkpoints found to resume from")
# Sort by timestamp to get latest
checkpoints.sort(key=lambda cp: cp.timestamp, reverse=True)
latest_checkpoint = checkpoints[0]
print(f"Restoring checkpoint {latest_checkpoint.checkpoint_id}")
# First, restore checkpoint to discover pending requests
restored_requests: list[WorkflowEvent] = []
async for event in workflow.run(checkpoint_id=latest_checkpoint.checkpoint_id, stream=True): # type: ignore[attr-defined]
if event.type == "request_info":
restored_requests.append(event)
if isinstance(event.data, HandoffAgentUserRequest):
_print_handoff_request(event.data, event.request_id)
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
_print_function_approval_request(event.data, event.request_id)
if not restored_requests:
raise RuntimeError("No pending requests found after checkpoint restoration")
responses = _build_responses_for_requests(
restored_requests,
user_response=user_response,
approve_tools=approve_tools,
)
print(f"Sending responses for {len(responses)} request(s)")
new_pending_requests: list[WorkflowEvent] = []
async for event in workflow.run(stream=True, responses=responses):
if event.type == "status":
print(f"[Status] {event.state}")
elif event.type == "output":
print("\n[Workflow Output Event - Conversation Update]")
if event.data and isinstance(event.data, list) and all(isinstance(msg, Message) for msg in event.data): # type: ignore
# Now safe to cast event.data to list[Message]
conversation = cast(list[Message], event.data) # type: ignore
for msg in conversation[-3:]: # Show last 3 messages
author = msg.author_name or msg.role
text = msg.text[:100] + "..." if len(msg.text) > 100 else msg.text
print(f" {author}: {text}")
elif event.type == "request_info":
new_pending_requests.append(event)
if isinstance(event.data, HandoffAgentUserRequest):
_print_handoff_request(event.data, event.request_id)
elif isinstance(event.data, Content) and event.data.type == "function_approval_request":
_print_function_approval_request(event.data, event.request_id)
return new_pending_requests, latest_checkpoint.checkpoint_id
async def main() -> None:
"""
Demonstrate the checkpoint-based pause/resume pattern for handoff workflows.
This sample shows:
1. Starting a workflow and getting a HandoffAgentUserRequest
2. Pausing (checkpoint is saved automatically)
3. Resuming from checkpoint with a user response or tool approval
4. Continuing the conversation until completion
"""
# Enable INFO logging to see workflow progress
logging.basicConfig(
level=logging.INFO,
format="[%(levelname)s] %(name)s: %(message)s",
)
# Clean up old checkpoints
for file in CHECKPOINT_DIR.glob("*.json"):
file.unlink()
for file in CHECKPOINT_DIR.glob("*.json.tmp"):
file.unlink()
storage = FileCheckpointStorage(storage_path=CHECKPOINT_DIR)
workflow, _, _, _ = create_workflow(checkpoint_storage=storage)
print("=" * 60)
print("HANDOFF WORKFLOW CHECKPOINT DEMO")
print("=" * 60)
# Scenario: User needs help with a damaged order
initial_request = "Hi, my order 12345 arrived damaged. I need a refund."
# Phase 1: Initial run - workflow will pause when it needs user input
pending_requests, _ = await run_until_user_input_needed(
workflow,
initial_message=initial_request,
)
if not pending_requests:
print("Workflow completed without needing user input")
return
print("\n>>> Workflow paused. You could exit the process here.")
print(f">>> Checkpoint was saved. Pending requests: {len(pending_requests)}")
# Scripted human input for demo purposes
handoff_responses = [
(
"The headphones in order 12345 arrived cracked. "
"Please submit the refund for $89.99 and send a replacement to my original address."
),
"Yes, that covers the damage and refund request.",
"That's everything I needed for the refund.",
"Thanks for handling the refund.",
]
approval_decisions = [True, True, True]
handoff_index = 0
approval_index = 0
while pending_requests:
print("\n>>> Simulating process restart...\n")
workflow_step, _, _, _ = create_workflow(checkpoint_storage=storage)
needs_user_input = any(isinstance(req.data, HandoffAgentUserRequest) for req in pending_requests)
needs_tool_approval = any(
isinstance(req.data, Content) and req.data.type == "function_approval_request" for req in pending_requests
)
user_response = None
if needs_user_input:
if handoff_index < len(handoff_responses):
user_response = handoff_responses[handoff_index]
handoff_index += 1
else:
user_response = handoff_responses[-1]
print(f">>> Responding to handoff request with: {user_response}")
approval_response = None
if needs_tool_approval:
if approval_index < len(approval_decisions):
approval_response = approval_decisions[approval_index]
approval_index += 1
else:
approval_response = approval_decisions[-1]
print(">>> Approving pending tool calls from the agent.")
pending_requests, _ = await resume_with_responses(
workflow_step,
storage,
user_response=user_response,
approve_tools=approval_response,
)
print("\n" + "=" * 60)
print("DEMO COMPLETE")
print("=" * 60)
if __name__ == "__main__":
asyncio.run(main())
@@ -345,14 +345,12 @@ async def main() -> None:
if request_id is None:
raise RuntimeError("Sub-workflow completed without requesting review.")
checkpoints = await storage.list_checkpoints(workflow.id)
if not checkpoints:
resume_checkpoint = await storage.get_latest(workflow_name=workflow.name)
if not resume_checkpoint:
raise RuntimeError("No checkpoints found.")
# Print the checkpoint to show pending requests
# We didn't handle the request above so the request is still pending the last checkpoint
checkpoints.sort(key=lambda cp: cp.timestamp)
resume_checkpoint = checkpoints[-1]
print(f"Using checkpoint {resume_checkpoint.checkpoint_id} at iteration {resume_checkpoint.iteration_count}")
checkpoint_path = storage.storage_path / f"{resume_checkpoint.checkpoint_id}.json"
@@ -69,7 +69,7 @@ async def basic_checkpointing() -> None:
print(f"[{speaker}]: {msg.text}")
# Show checkpoints that were created
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nCheckpoints created: {len(checkpoints)}")
for i, cp in enumerate(checkpoints[:5], 1):
print(f" {i}. {cp.checkpoint_id}")
@@ -110,7 +110,7 @@ async def checkpointing_with_thread() -> None:
print(f"[assistant]: {response2.messages[0].text}")
# Show accumulated state
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nTotal checkpoints across both turns: {len(checkpoints)}")
if thread.message_store:
@@ -147,7 +147,7 @@ async def streaming_with_checkpoints() -> None:
print() # Newline after streaming
checkpoints = await checkpoint_storage.list_checkpoints(workflow.id)
checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name)
print(f"\nCheckpoints created during stream: {len(checkpoints)}")
+144 -150
View File
@@ -1000,15 +1000,15 @@ wheels = [
[[package]]
name = "azure-core"
version = "1.38.0"
version = "1.38.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" }
sdist = { url = "https://files.pythonhosted.org/packages/53/9b/23893febea484ad8183112c9419b5eb904773adb871492b5fa8ff7b21e09/azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6", size = 363323, upload-time = "2026-02-11T02:03:06.051Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" },
{ url = "https://files.pythonhosted.org/packages/db/88/aaea2ad269ce70b446660371286272c1f6ba66541a7f6f635baf8b0db726/azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4", size = 217930, upload-time = "2026-02-11T02:03:07.548Z" },
]
[[package]]
@@ -1043,7 +1043,7 @@ wheels = [
[[package]]
name = "azure-identity"
version = "1.25.1"
version = "1.25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1052,9 +1052,9 @@ dependencies = [
{ name = "msal-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" }
sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" },
{ url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" },
]
[[package]]
@@ -1324,19 +1324,19 @@ wheels = [
[[package]]
name = "claude-agent-sdk"
version = "0.1.34"
version = "0.1.35"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9d/69/faeb64e9c8f0962cbf12bee1b959acc41f87c82947ec7074a6780b417001/claude_agent_sdk-0.1.34.tar.gz", hash = "sha256:db9e4023a754d9a58a0793666fe9174ead277197cd896156d2f8784cc73c5006", size = 61196, upload-time = "2026-02-10T01:04:00.585Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/b9/60f21337cfb0d029cbafefdb5de5d043a5d84e44f286f04dbadda7fda932/claude_agent_sdk-0.1.35.tar.gz", hash = "sha256:0f98e2b3c71ca85abfc042e7a35c648df88e87fda41c52e6779ef7b038dcbb52", size = 61194, upload-time = "2026-02-10T23:21:04.114Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/7b/2ccdc12b553a61b59b0470f1cf3b0a864c79ab8a4ac8013ea22fa3e6c461/claude_agent_sdk-0.1.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:18569ab4bfb5451c4aacb51c0d44eb9802d18d8442d30c29f32b6e8a2479d210", size = 54604881, upload-time = "2026-02-10T01:03:44.575Z" },
{ url = "https://files.pythonhosted.org/packages/38/59/335b213fb3342c4405fa992cc9e45e52e18a543068f0574ae84010dc4c08/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:d7ecd7421066e405376d3feca21ccb3e9245506ba7c219858f7a7f0129877cdb", size = 69359030, upload-time = "2026-02-10T01:03:49.113Z" },
{ url = "https://files.pythonhosted.org/packages/93/3d/28c715efdad7b5c413e046f5f914d9ab888d25f2c3bb9233f1164d58d2be/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:82e9148410ec98ff4061e43e85601d8f0a2e8568d897ab82c324ccf11c297fc5", size = 69949555, upload-time = "2026-02-10T01:03:53.525Z" },
{ url = "https://files.pythonhosted.org/packages/12/3d/843159343b20d6c9b44cf4a7fe46b568d5b448276ba8ba4c49178d26ba4c/claude_agent_sdk-0.1.34-py3-none-win_amd64.whl", hash = "sha256:a64031a9bf5c70388a6a84368d350d68586d9854a1539f494b46ec6d0b6acf93", size = 72493949, upload-time = "2026-02-10T01:03:57.839Z" },
{ url = "https://files.pythonhosted.org/packages/b9/1a/68ca97f034a1773bd234a4572e1a660d3f37b93e8ab9c8da95c36a10fd00/claude_agent_sdk-0.1.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:df67f4deade77b16a9678b3a626c176498e40417f33b04beda9628287f375591", size = 54665881, upload-time = "2026-02-10T23:20:48.107Z" },
{ url = "https://files.pythonhosted.org/packages/4e/56/535f23919882397571e15f4fb0418897ba9b527dc3a8a6c84b4f537486e0/claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:14963944f55ded7c8ed518feebfa5b4284aa6dd8d81aeff2e5b21a962ce65097", size = 69419673, upload-time = "2026-02-10T23:20:53.463Z" },
{ url = "https://files.pythonhosted.org/packages/e9/b5/763dda7d4d8c12a2ce485ef5cc98f2e7d2699bf3e0d8e2a7fb294d54c34b/claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:84344dcc535d179c1fc8a11c6f34c37c3b583447bdf09d869effb26514fd7a65", size = 70007339, upload-time = "2026-02-10T23:20:57.629Z" },
{ url = "https://files.pythonhosted.org/packages/9e/89/10f3d0355ee873203104714d2d728315ee5919c793e3626fab94a91ea29b/claude_agent_sdk-0.1.35-py3-none-win_amd64.whl", hash = "sha256:1b3d54b47448c93f6f372acd4d1757f047c3c1e8ef5804be7a1e3e53e2c79a5f", size = 72528072, upload-time = "2026-02-10T23:21:01.418Z" },
]
[[package]]
@@ -1356,7 +1356,7 @@ name = "clr-loader"
version = "0.2.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
{ name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" }
wheels = [
@@ -1835,7 +1835,7 @@ name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" },
{ name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" },
]
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 = [
@@ -1853,7 +1853,7 @@ wheels = [
[[package]]
name = "fastapi"
version = "0.128.6"
version = "0.128.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -1862,9 +1862,9 @@ dependencies = [
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/83/d1/195005b5e45b443e305136df47ee7df4493d782e0c039dd0d97065580324/fastapi-0.128.6.tar.gz", hash = "sha256:0cb3946557e792d731b26a42b04912f16367e3c3135ea8290f620e234f2b604f", size = 374757, upload-time = "2026-02-09T17:27:03.541Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/58/a2c4f6b240eeb148fb88cdac48f50a194aba760c1ca4988c6031c66a20ee/fastapi-0.128.6-py3-none-any.whl", hash = "sha256:bb1c1ef87d6086a7132d0ab60869d6f1ee67283b20fbf84ec0003bd335099509", size = 103674, upload-time = "2026-02-09T17:27:02.355Z" },
{ url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" },
]
[[package]]
@@ -2301,7 +2301,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/65/5b235b40581ad75ab97dcd8b4218022ae8e3ab77c13c919f1a1dfe9171fd/greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13", size = 273723, upload-time = "2026-01-23T15:30:37.521Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ad/eb4729b85cba2d29499e0a04ca6fbdd8f540afd7be142fd571eea43d712f/greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4", size = 574874, upload-time = "2026-01-23T16:00:54.551Z" },
{ url = "https://files.pythonhosted.org/packages/87/32/57cad7fe4c8b82fdaa098c89498ef85ad92dfbb09d5eb713adedfc2ae1f5/greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5", size = 586309, upload-time = "2026-01-23T16:05:25.18Z" },
{ url = "https://files.pythonhosted.org/packages/66/66/f041005cb87055e62b0d68680e88ec1a57f4688523d5e2fb305841bc8307/greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5", size = 597461, upload-time = "2026-01-23T16:15:51.943Z" },
{ url = "https://files.pythonhosted.org/packages/87/eb/8a1ec2da4d55824f160594a75a9d8354a5fe0a300fb1c48e7944265217e1/greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe", size = 586985, upload-time = "2026-01-23T15:32:47.968Z" },
{ url = "https://files.pythonhosted.org/packages/15/1c/0621dd4321dd8c351372ee8f9308136acb628600658a49be1b7504208738/greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729", size = 1547271, upload-time = "2026-01-23T16:04:18.977Z" },
{ url = "https://files.pythonhosted.org/packages/9d/53/24047f8924c83bea7a59c8678d9571209c6bfe5f4c17c94a78c06024e9f2/greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4", size = 1613427, upload-time = "2026-01-23T15:33:44.428Z" },
@@ -2309,7 +2308,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" },
{ url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" },
{ url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" },
{ url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" },
{ url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" },
{ url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" },
@@ -2318,7 +2316,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" },
{ url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" },
{ url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" },
{ url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" },
{ url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" },
{ url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" },
{ url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" },
@@ -2327,7 +2324,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" },
{ url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" },
{ url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" },
{ url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" },
{ url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" },
{ url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" },
{ url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" },
@@ -2336,7 +2332,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" },
{ url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" },
{ url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" },
{ url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" },
{ url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" },
{ url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" },
{ url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" },
@@ -2345,7 +2340,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" },
{ url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" },
{ url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" },
{ url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" },
{ url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" },
{ url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" },
{ url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" },
@@ -3059,7 +3053,7 @@ wheels = [
[[package]]
name = "litellm"
version = "1.81.9"
version = "1.81.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -3075,9 +3069,9 @@ dependencies = [
{ name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ff/8f/2a08f3d86fd008b4b02254649883032068378a8551baed93e8d9dcbbdb5d/litellm-1.81.9.tar.gz", hash = "sha256:a2cd9bc53a88696c21309ef37c55556f03c501392ed59d7f4250f9932917c13c", size = 16276983, upload-time = "2026-02-07T21:14:24.473Z" }
sdist = { url = "https://files.pythonhosted.org/packages/40/fc/78887158b4057835ba2c647a1bd4da650fd79142f8412c6d0bbe6d8c6081/litellm-1.81.10.tar.gz", hash = "sha256:8d769a7200888e1295592af5ce5cb0ff035832250bd0102a4ca50acf5820ca50", size = 16297572, upload-time = "2026-02-11T00:17:47.347Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0b/8b/672fc06c8a2803477e61e0de383d3c6e686e0f0fc62789c21f0317494076/litellm-1.81.9-py3-none-any.whl", hash = "sha256:24ee273bc8a62299fbb754035f83fb7d8d44329c383701a2bd034f4fd1c19084", size = 14433170, upload-time = "2026-02-07T21:14:21.469Z" },
{ url = "https://files.pythonhosted.org/packages/b1/bb/3f3cc3d79657bc9daaa1319ec3a9d75e4889fc88d07e327f0ac02cd2ac7d/litellm-1.81.10-py3-none-any.whl", hash = "sha256:9efa1cbe61ac051f6500c267b173d988ff2d511c2eecf1c8f2ee546c0870747c", size = 14457931, upload-time = "2026-02-11T00:17:43.431Z" },
]
[package.optional-dependencies]
@@ -3119,11 +3113,11 @@ wheels = [
[[package]]
name = "litellm-proxy-extras"
version = "0.4.33"
version = "0.4.34"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/51/4f/1e8644cdda2892d2dc8151153ca4d8a6fc44000363677a52f9988e56713a/litellm_proxy_extras-0.4.33.tar.gz", hash = "sha256:133dc5476b540d99e75d4baef622267e7344ced97737c174679baff429e7f212", size = 23973, upload-time = "2026-02-07T19:07:32.67Z" }
sdist = { url = "https://files.pythonhosted.org/packages/8c/0f/e04f9718ddfc7a87b682e0eb98f18a5179dbe497e5d02a76ebe6aaae7269/litellm_proxy_extras-0.4.34.tar.gz", hash = "sha256:39fa6c2295acc449320b5a710d150295fd0bf5f8c0d1742b5e9ae361d7bd3ed2", size = 24232, upload-time = "2026-02-10T21:59:31.948Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/c0/b9960391b983306c39f1fa28e2eedf5d0e2048879fde8707a2d80896ed10/litellm_proxy_extras-0.4.33-py3-none-any.whl", hash = "sha256:bebea1b091490df19cfa773bd311f08254dee5bb53f92d282b7a5bdfba936334", size = 52533, upload-time = "2026-02-07T19:07:31.665Z" },
{ url = "https://files.pythonhosted.org/packages/21/71/a32bdfa74c598dde072d860ba1facaa522b4ef75c07c894c614999e73d75/litellm_proxy_extras-0.4.34-py3-none-any.whl", hash = "sha256:d455eb54f82e7c92f4f68a921240822df23158aad05fcdda7245887db7c30b90", size = 53171, upload-time = "2026-02-10T21:59:30.728Z" },
]
[[package]]
@@ -3884,7 +3878,7 @@ wheels = [
[[package]]
name = "openai"
version = "2.18.0"
version = "2.20.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
@@ -3896,9 +3890,9 @@ dependencies = [
{ name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/cb/f2c9f988a06d1fcdd18ddc010f43ac384219a399eb01765493d6b34b1461/openai-2.18.0.tar.gz", hash = "sha256:5018d3bcb6651c5aac90e6d0bf9da5cde1bdd23749f67b45b37c522b6e6353af", size = 632124, upload-time = "2026-02-09T21:42:18.017Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6e/5a/f495777c02625bfa18212b6e3b73f1893094f2bf660976eb4bc6f43a1ca2/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1", size = 642355, upload-time = "2026-02-10T19:02:54.145Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/5f/8940e0641c223eaf972732b3154f2178a968290f8cb99e8c88582cde60ed/openai-2.18.0-py3-none-any.whl", hash = "sha256:538f97e1c77a00e3a99507688c878cda7e9e63031807ba425c68478854d48b30", size = 1069897, upload-time = "2026-02-09T21:42:16.4Z" },
{ url = "https://files.pythonhosted.org/packages/b5/a0/cf4297aa51bbc21e83ef0ac018947fa06aea8f2364aad7c96cbf148590e6/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99", size = 1098479, upload-time = "2026-02-10T19:02:52.157Z" },
]
[[package]]
@@ -4353,100 +4347,100 @@ wheels = [
[[package]]
name = "pillow"
version = "12.1.0"
version = "12.1.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" },
{ url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" },
{ url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" },
{ url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" },
{ url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" },
{ url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" },
{ url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" },
{ url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" },
{ url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" },
{ url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" },
{ url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" },
{ url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" },
{ url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" },
{ url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" },
{ url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" },
{ url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" },
{ url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" },
{ url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" },
{ url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" },
{ url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" },
{ url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" },
{ url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" },
{ url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" },
{ url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" },
{ url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" },
{ url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" },
{ url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" },
{ url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" },
{ url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" },
{ url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" },
{ url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" },
{ url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" },
{ url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" },
{ url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" },
{ url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" },
{ url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" },
{ url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" },
{ url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" },
{ url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" },
{ url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" },
{ url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" },
{ url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" },
{ url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" },
{ url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" },
{ url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" },
{ url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" },
{ url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" },
{ url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" },
{ url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" },
{ url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" },
{ url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" },
{ url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" },
{ url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" },
{ url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" },
{ url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" },
{ url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" },
{ url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" },
{ url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" },
{ url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" },
{ url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" },
{ url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" },
{ url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" },
{ url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" },
{ url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" },
{ url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" },
{ url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" },
{ url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" },
{ url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" },
{ url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" },
{ url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" },
{ url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" },
{ url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" },
{ url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" },
{ url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" },
{ url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" },
{ url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" },
{ url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" },
{ url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" },
{ url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" },
{ url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" },
{ url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" },
{ url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" },
{ url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" },
{ url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" },
{ url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" },
{ url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" },
{ url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" },
{ url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" },
{ url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" },
{ url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" },
{ url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" },
{ url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" },
{ url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" },
{ url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" },
{ url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" },
{ url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" },
{ url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" },
{ url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" },
{ url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" },
{ url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" },
{ url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" },
{ url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" },
{ url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" },
{ url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" },
{ url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" },
{ url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" },
{ url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" },
{ url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" },
{ url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" },
{ url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" },
{ url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" },
{ url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" },
{ url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" },
{ url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" },
{ url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" },
{ url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" },
{ url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" },
{ url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" },
{ url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" },
{ url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" },
{ url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" },
{ url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" },
{ url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" },
{ url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" },
{ url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" },
{ url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" },
{ url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" },
{ url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" },
{ url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" },
{ url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" },
{ url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" },
{ url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" },
{ url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" },
{ url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" },
{ url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" },
{ url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" },
{ url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" },
{ url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" },
{ url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" },
{ url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" },
{ url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" },
{ url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" },
{ url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" },
{ url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" },
{ url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" },
{ url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" },
{ url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" },
{ url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" },
{ url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" },
{ url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" },
{ url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" },
{ url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" },
{ url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" },
{ url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" },
{ url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" },
{ url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" },
{ url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" },
{ url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" },
{ url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" },
{ url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" },
{ url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" },
{ url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" },
{ url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" },
{ url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" },
{ url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" },
{ url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" },
{ url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" },
]
[[package]]
@@ -4565,8 +4559,8 @@ name = "powerfx"
version = "0.0.34"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
{ name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
{ name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" }
wheels = [
@@ -5215,7 +5209,7 @@ name = "pythonnet"
version = "3.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" },
{ name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" }
wheels = [
@@ -6471,15 +6465,15 @@ wheels = [
[[package]]
name = "typer-slim"
version = "0.21.1"
version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
{ name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" }
sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" },
{ url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" },
]
[[package]]
@@ -6565,27 +6559,27 @@ wheels = [
[[package]]
name = "uv"
version = "0.10.0"
version = "0.10.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/09/36/f7fe4de0ad81234ac43938fe39c6ba84595c6b3a1868d786a4d7ad19e670/uv-0.10.0.tar.gz", hash = "sha256:ad01dd614a4bb8eb732da31ade41447026427397c5ad171cc98bd59579ef57ea", size = 3854103, upload-time = "2026-02-05T20:57:55.248Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0d/9a/fe74aa0127cdc26141364e07abf25e5d69b4bf9788758fad9cfecca637aa/uv-0.10.2.tar.gz", hash = "sha256:b5016f038e191cc9ef00e17be802f44363d1b1cc3ef3454d1d76839a4246c10a", size = 3858864, upload-time = "2026-02-10T19:17:51.609Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/69/33fb64aee6ba138b1aaf957e20778e94a8c23732e41cdf68e6176aa2cf4e/uv-0.10.0-py3-none-linux_armv6l.whl", hash = "sha256:38dc0ccbda6377eb94095688c38e5001b8b40dfce14b9654949c1f0b6aa889df", size = 21984662, upload-time = "2026-02-05T20:57:19.076Z" },
{ url = "https://files.pythonhosted.org/packages/1a/5a/e3ff8a98cfbabc5c2d09bf304d2d9d2d7b2e7d60744241ac5ed762015e5c/uv-0.10.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a165582c1447691109d49d09dccb065d2a23852ff42bf77824ff169909aa85da", size = 21057249, upload-time = "2026-02-05T20:56:48.921Z" },
{ url = "https://files.pythonhosted.org/packages/ee/77/ec8f24f8d0f19c4fda0718d917bb78b9e6f02a4e1963b401f1c4f4614a54/uv-0.10.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aefea608971f4f23ac3dac2006afb8eb2b2c1a2514f5fee1fac18e6c45fd70c4", size = 19827174, upload-time = "2026-02-05T20:57:10.581Z" },
{ url = "https://files.pythonhosted.org/packages/c6/7e/09b38b93208906728f591f66185a425be3acdb97c448460137d0e6ecb30a/uv-0.10.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d4b621bcc5d0139502789dc299bae8bf55356d07b95cb4e57e50e2afcc5f43e1", size = 21629522, upload-time = "2026-02-05T20:57:29.959Z" },
{ url = "https://files.pythonhosted.org/packages/89/f3/48d92c90e869331306979efaa29a44c3e7e8376ae343edc729df0d534dfb/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b4bea728a6b64826d0091f95f28de06dd2dc786384b3d336a90297f123b4da0e", size = 21614812, upload-time = "2026-02-05T20:56:58.103Z" },
{ url = "https://files.pythonhosted.org/packages/ff/43/d0dedfcd4fe6e36cabdbeeb43425cd788604db9d48425e7b659d0f7ba112/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc0cc2a4bcf9efbff9a57e2aed21c2d4b5a7ec2cc0096e0c33d7b53da17f6a3b", size = 21577072, upload-time = "2026-02-05T20:57:45.455Z" },
{ url = "https://files.pythonhosted.org/packages/c5/90/b8c9320fd8d86f356e37505a02aa2978ed28f9c63b59f15933e98bce97e5/uv-0.10.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:070ca2f0e8c67ca9a8f70ce403c956b7ed9d51e0c2e9dbbcc4efa5e0a2483f79", size = 22829664, upload-time = "2026-02-05T20:57:22.689Z" },
{ url = "https://files.pythonhosted.org/packages/56/9c/2c36b30b05c74b2af0e663e0e68f1d10b91a02a145e19b6774c121120c0b/uv-0.10.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8070c66149c06f9b39092a06f593a2241345ea2b1d42badc6f884c2cc089a1b1", size = 23705815, upload-time = "2026-02-05T20:57:37.604Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a1/8c7fdb14ab72e26ca872e07306e496a6b8cf42353f9bf6251b015be7f535/uv-0.10.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db1d5390b3a624de672d7b0f9c9d8197693f3b2d3d9c4d9e34686dcbc34197a", size = 22890313, upload-time = "2026-02-05T20:57:26.35Z" },
{ url = "https://files.pythonhosted.org/packages/f3/f8/5c152350b1a6d0af019801f91a1bdeac854c33deb36275f6c934f0113cb5/uv-0.10.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b46db718763bf742e986ebbc7a30ca33648957a0dcad34382970b992f5e900", size = 22769440, upload-time = "2026-02-05T20:56:53.859Z" },
{ url = "https://files.pythonhosted.org/packages/87/44/980e5399c6f4943b81754be9b7deb87bd56430e035c507984e17267d6a97/uv-0.10.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:eb95d28590edd73b8fdd80c27d699c45c52f8305170c6a90b830caf7f36670a4", size = 21695296, upload-time = "2026-02-05T20:57:06.732Z" },
{ url = "https://files.pythonhosted.org/packages/ae/e7/f44ad40275be2087b3910df4678ed62cf0c82eeb3375c4a35037a79747db/uv-0.10.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5871eef5046a81df3f1636a3d2b4ccac749c23c7f4d3a4bae5496cb2876a1814", size = 22424291, upload-time = "2026-02-05T20:57:49.067Z" },
{ url = "https://files.pythonhosted.org/packages/c2/81/31c0c0a8673140756e71a1112bf8f0fcbb48a4cf4587a7937f5bd55256b6/uv-0.10.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1af0ec125a07edb434dfaa98969f6184c1313dbec2860c3c5ce2d533b257132a", size = 22109479, upload-time = "2026-02-05T20:57:02.258Z" },
{ url = "https://files.pythonhosted.org/packages/d7/d1/2eb51bc233bad3d13ad64a0c280fd4d1ebebf5c2939b3900a46670fa2b91/uv-0.10.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:45909b9a734250da05b10101e0a067e01ffa2d94bbb07de4b501e3cee4ae0ff3", size = 22972087, upload-time = "2026-02-05T20:57:52.847Z" },
{ url = "https://files.pythonhosted.org/packages/d2/f7/49987207b87b5c21e1f0e81c52892813e8cdf7e318b6373d6585773ebcdd/uv-0.10.0-py3-none-win32.whl", hash = "sha256:d5498851b1f07aa9c9af75578b2029a11743cb933d741f84dcbb43109a968c29", size = 20896746, upload-time = "2026-02-05T20:57:33.426Z" },
{ url = "https://files.pythonhosted.org/packages/80/b2/1370049596c6ff7fa1fe22fccf86a093982eac81017b8c8aff541d7263b2/uv-0.10.0-py3-none-win_amd64.whl", hash = "sha256:edd469425cd62bcd8c8cc0226c5f9043a94e37ed869da8268c80fdbfd3e5015e", size = 23433041, upload-time = "2026-02-05T20:57:41.41Z" },
{ url = "https://files.pythonhosted.org/packages/e3/76/1034c46244feafec2c274ac52b094f35d47c94cdb11461c24cf4be8a0c0c/uv-0.10.0-py3-none-win_arm64.whl", hash = "sha256:e90c509749b3422eebb54057434b7119892330d133b9690a88f8a6b0f3116be3", size = 21880261, upload-time = "2026-02-05T20:57:14.724Z" },
{ url = "https://files.pythonhosted.org/packages/ec/b5/aea88f66284d220be56ef748ed5e1bd11d819be14656a38631f4b55bfd48/uv-0.10.2-py3-none-linux_armv6l.whl", hash = "sha256:69e35aa3e91a245b015365e5e6ca383ecf72a07280c6d00c17c9173f2d3b68ab", size = 22215714, upload-time = "2026-02-10T19:17:34.281Z" },
{ url = "https://files.pythonhosted.org/packages/7f/72/947ba7737ae6cd50de61d268781b9e7717caa3b07e18238ffd547f9fc728/uv-0.10.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0b7eef95c36fe92e7aac399c0dce555474432cbfeaaa23975ed83a63923f78fd", size = 21276485, upload-time = "2026-02-10T19:18:15.415Z" },
{ url = "https://files.pythonhosted.org/packages/d3/38/5c3462b927a93be4ccaaa25138926a5fb6c9e1b72884efd7af77e451d82e/uv-0.10.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:acc08e420abab21de987151059991e3f04bc7f4044d94ca58b5dd547995b4843", size = 20048620, upload-time = "2026-02-10T19:17:26.481Z" },
{ url = "https://files.pythonhosted.org/packages/03/51/d4509b0f5b7740c1af82202e9c69b700d5848b8bd0faa25229e8edd2c19c/uv-0.10.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:aefbcd749ab2ad48bb533ec028607607f7b03be11c83ea152dbb847226cd6285", size = 21870454, upload-time = "2026-02-10T19:17:21.838Z" },
{ url = "https://files.pythonhosted.org/packages/cd/7e/2bcbafcb424bb885817a7e58e6eec9314c190c55935daaafab1858bb82cd/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fad554c38d9988409ceddfac69a465e6e5f925a8b689e7606a395c20bb4d1d78", size = 21839508, upload-time = "2026-02-10T19:17:59.211Z" },
{ url = "https://files.pythonhosted.org/packages/60/08/16df2c1f8ad121a595316b82f6e381447e8974265b2239c9135eb874f33b/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6dd2dc41043e92b3316d7124a7bf48c2affe7117c93079419146f083df71933c", size = 21841283, upload-time = "2026-02-10T19:17:41.419Z" },
{ url = "https://files.pythonhosted.org/packages/76/27/a869fec4c03af5e43db700fabe208d8ee8dbd56e0ff568ba792788d505cd/uv-0.10.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111c05182c5630ac523764e0ec2e58d7b54eb149dbe517b578993a13c2f71aff", size = 23111967, upload-time = "2026-02-10T19:18:11.764Z" },
{ url = "https://files.pythonhosted.org/packages/2a/4a/fb38515d966acfbd80179e626985aab627898ffd02c70205850d6eb44df1/uv-0.10.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c3deaba0343fd27ab5385d6b7cde0765df1a15389ee7978b14a51c32895662", size = 23911019, upload-time = "2026-02-10T19:18:26.947Z" },
{ url = "https://files.pythonhosted.org/packages/dd/5f/51bcbb490ddb1dcb06d767f0bde649ad2826686b9e30efa57f8ab2750a1d/uv-0.10.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb2cac4f3be60b64a23d9f035019c30a004d378b563c94f60525c9591665a56b", size = 23030217, upload-time = "2026-02-10T19:17:37.789Z" },
{ url = "https://files.pythonhosted.org/packages/46/69/144f6db851d49aa6f25b040dc5c8c684b8f92df9e8d452c7abc619c6ec23/uv-0.10.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937687df0380d636ceafcb728cf6357f0432588e721892128985417b283c3b54", size = 23036452, upload-time = "2026-02-10T19:18:18.97Z" },
{ url = "https://files.pythonhosted.org/packages/66/29/3c7c4559c9310ed478e3d6c585ee0aad2852dc4d5fb14f4d92a2a12d1728/uv-0.10.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f90bca8703ae66bccfcfb7313b4b697a496c4d3df662f4a1a2696a6320c47598", size = 21941903, upload-time = "2026-02-10T19:17:30.575Z" },
{ url = "https://files.pythonhosted.org/packages/9a/5a/42883b5ef2ef0b1bc5b70a1da12a6854a929ff824aa8eb1a5571fb27a39b/uv-0.10.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cca026c2e584788e1264879a123bf499dd8f169b9cafac4a2065a416e09d3823", size = 22651571, upload-time = "2026-02-10T19:18:22.74Z" },
{ url = "https://files.pythonhosted.org/packages/e8/b8/e4f1dda1b3b0cc6c8ac06952bfe7bc28893ff016fb87651c8fafc6dfca96/uv-0.10.2-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9f878837938103ee1307ed3ed5d9228118e3932816ab0deb451e7e16dc8ce82a", size = 22321279, upload-time = "2026-02-10T19:17:49.402Z" },
{ url = "https://files.pythonhosted.org/packages/2c/4b/baa16d46469e024846fc1a8aa0cfa63f1f89ad0fd3eaa985359a168c3fb0/uv-0.10.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6ec75cfe638b316b329474aa798c3988e5946ead4d9e977fe4dc6fc2ea3e0b8b", size = 23252208, upload-time = "2026-02-10T19:17:54.46Z" },
{ url = "https://files.pythonhosted.org/packages/d6/84/6a74e5ec2ee90e4314905e6d1d1708d473e06405e492ec38868b42645388/uv-0.10.2-py3-none-win32.whl", hash = "sha256:f7f3c7e09bf53b81f55730a67dd86299158f470dffb2bd279b6432feb198d231", size = 21118543, upload-time = "2026-02-10T19:18:07.296Z" },
{ url = "https://files.pythonhosted.org/packages/dd/f9/e5cc6cf3a578b87004e857274df97d3cdecd8e19e965869b9b67c094c20c/uv-0.10.2-py3-none-win_amd64.whl", hash = "sha256:7b3685aa1da15acbe080b4cba8684afbb6baf11c9b04d4d4b347cc18b7b9cfa0", size = 23620790, upload-time = "2026-02-10T19:17:45.204Z" },
{ url = "https://files.pythonhosted.org/packages/df/7a/99979dc08ae6a65f4f7a44c5066699016c6eecdc4e695b7512c2efb53378/uv-0.10.2-py3-none-win_arm64.whl", hash = "sha256:abdd5b3c6b871b17bf852a90346eb7af881345706554fd082346b000a9393afd", size = 22035199, upload-time = "2026-02-10T19:18:03.679Z" },
]
[[package]]