mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[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:
committed by
GitHub
Unverified
parent
a2a672b687
commit
7db6c4ab4e
+5
-3
@@ -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)
|
||||
|
||||
+6
-8
@@ -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"
|
||||
|
||||
+230
@@ -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(
|
||||
|
||||
+4
-33
@@ -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})"
|
||||
|
||||
-405
@@ -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)}")
|
||||
|
||||
|
||||
|
||||
Generated
+144
-150
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user