diff --git a/python/.cspell.json b/python/.cspell.json index db575845e8..a26cc7fed7 100644 --- a/python/.cspell.json +++ b/python/.cspell.json @@ -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", diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index e573c51e23..3eb65335c9 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -13,7 +13,6 @@ InMemoryCheckpointStorage, WorkflowCheckpoint, ) -from ._checkpoint_summary import WorkflowCheckpointSummary, get_checkpoint_summary from ._const import ( DEFAULT_MAX_ITERATIONS, ) @@ -107,7 +106,6 @@ "WorkflowBuilder", "WorkflowCheckpoint", "WorkflowCheckpointException", - "WorkflowCheckpointSummary", "WorkflowContext", "WorkflowConvergenceException", "WorkflowErrorDetails", @@ -124,7 +122,6 @@ "WorkflowViz", "create_edge_runner", "executor", - "get_checkpoint_summary", "handler", "resolve_agent_id", "response_handler", diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 0923e5c93c..8290391fb9 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -13,9 +13,7 @@ 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 @@ async def on_checkpoint_save(self) -> dict[str, Any]: 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 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: 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 @@ async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: 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.""" diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index 0334ee3893..4d3f87b89e 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -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 - - 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] + return checkpoint + raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}") - 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 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 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 @@ async def delete_checkpoint(self, checkpoint_id: str) -> bool: 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 @@ def __init__(self, storage_path: str | Path): 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 @@ def _write_atomic() -> None: 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) + + from ._checkpoint_encoding import CheckpointDecodingError, decode_checkpoint_value - checkpoint = WorkflowCheckpoint(**checkpoint_dict) + 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.""" - - 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 + async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]: + """List checkpoint objects for a given workflow name. - return await asyncio.to_thread(_list_ids) + Args: + workflow_name: The name of the workflow to list checkpoints for. - 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 @@ def _delete() -> bool: 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) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index 644744c798..524f291c5e 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -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 = "" +"""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. - 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 "" - - # 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) + Returns: + A JSON-serializable representation of the value. + """ + 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 - - 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 + return {str(k): _encode(v) for k, v in value.items()} # type: ignore + + # 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. + + 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). - Checks for pairs of serialization/deserialization methods: - - to_dict/from_dict - - to_json/from_json + 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)) + 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." + ) - 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) +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 _supports_model_protocol(obj: object) -> bool: - """Detect objects that expose dictionary serialization hooks.""" - 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 +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: - 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__}" diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py b/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py deleted file mode 100644 index fe00c1a287..0000000000 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py +++ /dev/null @@ -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, - ) diff --git a/python/packages/core/agent_framework/_workflows/_conversation_state.py b/python/packages/core/agent_framework/_workflows/_conversation_state.py deleted file mode 100644 index 95945998df..0000000000 --- a/python/packages/core/agent_framework/_workflows/_conversation_state.py +++ /dev/null @@ -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 diff --git a/python/packages/core/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py index 18e974e3e7..c4694bf31b 100644 --- a/python/packages/core/agent_framework/_workflows/_events.py +++ b/python/packages/core/agent_framework/_workflows/_events.py @@ -12,7 +12,6 @@ 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 @@ def to_dict(self) -> dict[str, Any]: 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 @@ def from_dict(cls, data: dict[str, Any]) -> WorkflowEvent[Any]: 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): diff --git a/python/packages/core/agent_framework/_workflows/_runner.py b/python/packages/core/agent_framework/_workflows/_runner.py index 83ce5d8085..88281597a2 100644 --- a/python/packages/core/agent_framework/_workflows/_runner.py +++ b/python/packages/core/agent_framework/_workflows/_runner.py @@ -7,12 +7,7 @@ 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 @@ def __init__( 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 @@ def __init__( 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 @@ async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]: 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 @@ async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]: 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 @@ async def run_until_convergence(self) -> AsyncGenerator[WorkflowEvent, None]: 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 @@ async def _deliver_message_inner(edge_runner: EdgeRunner, message: WorkflowMessa """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 @@ def _normalize_message_payload(message: WorkflowMessage) -> None: 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 @@ def _normalize_message_payload(message: WorkflowMessage) -> None: 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 @@ async def restore_from_checkpoint( 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 @@ async def restore_from_checkpoint( 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 @@ async def restore_from_checkpoint( 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 @@ async def _restore_executor_states(self) -> None: 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. diff --git a/python/packages/core/agent_framework/_workflows/_runner_context.py b/python/packages/core/agent_framework/_workflows/_runner_context.py index db6558306a..d52e135e91 100644 --- a/python/packages/core/agent_framework/_workflows/_runner_context.py +++ b/python/packages/core/agent_framework/_workflows/_runner_context.py @@ -4,25 +4,17 @@ 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 @@ def source_span_id(self) -> str | None: 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 @@ def from_dict(data: dict[str, Any]) -> 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 @@ def clear_runtime_checkpoint_storage(self) -> None: """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 @@ def is_streaming(self) -> bool: 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 @@ async def create_checkpoint( """ ... - 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 @@ def __init__(self, checkpoint_storage: CheckpointStorage | None = None): # 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 @@ def has_checkpointing(self) -> bool: 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 @@ async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None: 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 @@ def is_streaming(self) -> bool: """ 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) diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 88a92dc703..cd7dbb4a68 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -175,9 +175,9 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def _reset_running_flag(self) -> None: 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 @@ def to_dict(self) -> dict[str, Any]: "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 @@ async def _run_core( ): 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 @@ def _compute_graph_signature(self) -> dict[str, Any]: 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 @@ def _compute_graph_signature(self) -> dict[str, Any]: "start_executor": self.start_executor_id, "executors": executors_signature, "edge_groups": edge_groups_signature, - "max_iterations": self.max_iterations, } @staticmethod @@ -804,10 +815,6 @@ def _hash_graph_signature(signature: dict[str, Any]) -> str: 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. diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index d7bdf9a918..1a71b3a49b 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -2,6 +2,7 @@ import logging import sys +import uuid from collections.abc import Callable, Sequence from typing import Any @@ -88,7 +89,11 @@ def __init__( 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 @@ def __init__( 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 @@ async def process(self, text: str, ctx: WorkflowContext[Never, str]) -> None: 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 diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 3e5fd449bc..0d2c86070c 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -11,7 +11,7 @@ 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 @@ async def on_checkpoint_save(self) -> dict[str, Any]: """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 @@ async def _handle_response( 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: diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index c97ae0168a..cef2d5c47d 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -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" diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index d3cef6f1fa..b4f431fd84 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -84,15 +84,16 @@ 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 + 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." + ) - # Find a suitable checkpoint to restore (prefer superstep checkpoint) + # 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 = next( - (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"), - checkpoints[-1], - ) + restore_checkpoint = checkpoints[1] # Verify checkpoint contains executor state with both cache and thread assert "_executor_state" in restore_checkpoint.state diff --git a/python/packages/core/tests/workflow/test_checkpoint.py b/python/packages/core/tests/workflow/test_checkpoint.py index 9f6d57b2e1..b05d625502 100644 --- a/python/packages/core/tests/workflow/test_checkpoint.py +++ b/python/packages/core/tests/workflow/test_checkpoint.py @@ -2,21 +2,66 @@ import json import tempfile +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path +import pytest + from agent_framework import ( FileCheckpointStorage, InMemoryCheckpointStorage, WorkflowCheckpoint, + WorkflowCheckpointException, + WorkflowEvent, ) +from agent_framework._workflows._runner_context import WorkflowMessage + + +# Module-level dataclasses for pickle serialization in roundtrip tests +@dataclass +class _TestToolApprovalRequest: + """Request data for tool approval in tests.""" + + tool_name: str + arguments: dict + timestamp: datetime + + +@dataclass +class _TestExecutorState: + """Executor state for tests.""" + + counter: int + history: list[str] + + +@dataclass +class _TestApprovalRequest: + """Approval request data for tests.""" + + action: str + params: tuple + + +@dataclass +class _TestCustomData: + """Custom data for tests.""" + + name: str + value: int + tags: list[str] + + +# region test WorkflowCheckpoint def test_workflow_checkpoint_default_values(): - checkpoint = WorkflowCheckpoint() + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") assert checkpoint.checkpoint_id != "" - assert checkpoint.workflow_id == "" + assert checkpoint.workflow_name == "test-workflow" + assert checkpoint.graph_signature_hash == "test-hash" assert checkpoint.timestamp != "" assert checkpoint.messages == {} assert checkpoint.state == {} @@ -30,7 +75,8 @@ def test_workflow_checkpoint_custom_values(): custom_timestamp = datetime.now(timezone.utc).isoformat() checkpoint = WorkflowCheckpoint( checkpoint_id="test-checkpoint-123", - workflow_id="test-workflow-456", + workflow_name="test-workflow-456", + graph_signature_hash="test-hash-456", timestamp=custom_timestamp, messages={"executor1": [{"data": "test"}]}, pending_request_info_events={"req123": {"data": "test"}}, @@ -41,7 +87,8 @@ def test_workflow_checkpoint_custom_values(): ) assert checkpoint.checkpoint_id == "test-checkpoint-123" - assert checkpoint.workflow_id == "test-workflow-456" + assert checkpoint.workflow_name == "test-workflow-456" + assert checkpoint.graph_signature_hash == "test-hash-456" assert checkpoint.timestamp == custom_timestamp assert checkpoint.messages == {"executor1": [{"data": "test"}]} assert checkpoint.state == {"key": "value"} @@ -51,23 +98,83 @@ def test_workflow_checkpoint_custom_values(): assert checkpoint.version == "2.0" +def test_workflow_checkpoint_to_dict(): + checkpoint = WorkflowCheckpoint( + checkpoint_id="test-id", + workflow_name="test-workflow", + graph_signature_hash="test-hash", + messages={"executor1": [{"data": "test"}]}, + state={"key": "value"}, + iteration_count=5, + ) + + result = checkpoint.to_dict() + + assert result["checkpoint_id"] == "test-id" + assert result["workflow_name"] == "test-workflow" + assert result["graph_signature_hash"] == "test-hash" + assert result["messages"] == {"executor1": [{"data": "test"}]} + assert result["state"] == {"key": "value"} + assert result["iteration_count"] == 5 + + +def test_workflow_checkpoint_previous_checkpoint_id(): + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + previous_checkpoint_id="previous-id-123", + ) + + assert checkpoint.previous_checkpoint_id == "previous-id-123" + + +# endregion + +# region InMemoryCheckpointStorage + + +def test_checkpoint_storage_protocol_compliance(): + # This test ensures both implementations have all required methods + memory_storage = InMemoryCheckpointStorage() + + with tempfile.TemporaryDirectory() as temp_dir: + file_storage = FileCheckpointStorage(temp_dir) + + for storage in [memory_storage, file_storage]: + # Test that all protocol methods exist and are callable + assert hasattr(storage, "save") + assert callable(storage.save) + assert hasattr(storage, "load") + assert callable(storage.load) + assert hasattr(storage, "list_checkpoints") + assert callable(storage.list_checkpoints) + assert hasattr(storage, "delete") + assert callable(storage.delete) + assert hasattr(storage, "list_checkpoint_ids") + assert callable(storage.list_checkpoint_ids) + assert hasattr(storage, "get_latest") + assert callable(storage.get_latest) + + async def test_memory_checkpoint_storage_save_and_load(): storage = InMemoryCheckpointStorage() checkpoint = WorkflowCheckpoint( - workflow_id="test-workflow", + workflow_name="test-workflow", + graph_signature_hash="test-hash", messages={"executor1": [{"data": "hello"}]}, pending_request_info_events={"req123": {"data": "test"}}, ) # Save checkpoint - saved_id = await storage.save_checkpoint(checkpoint) + saved_id = await storage.save(checkpoint) assert saved_id == checkpoint.checkpoint_id # Load checkpoint - loaded_checkpoint = await storage.load_checkpoint(checkpoint.checkpoint_id) + loaded_checkpoint = await storage.load(checkpoint.checkpoint_id) assert loaded_checkpoint is not None assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id - assert loaded_checkpoint.workflow_id == checkpoint.workflow_id + assert loaded_checkpoint.workflow_name == checkpoint.workflow_name + assert loaded_checkpoint.graph_signature_hash == checkpoint.graph_signature_hash assert loaded_checkpoint.messages == checkpoint.messages assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events @@ -75,96 +182,607 @@ async def test_memory_checkpoint_storage_save_and_load(): async def test_memory_checkpoint_storage_load_nonexistent(): storage = InMemoryCheckpointStorage() - result = await storage.load_checkpoint("nonexistent-id") - assert result is None + with pytest.raises(WorkflowCheckpointException): + await storage.load("nonexistent-id") -async def test_memory_checkpoint_storage_list_checkpoints(): +async def test_memory_checkpoint_storage_list(): storage = InMemoryCheckpointStorage() # Create checkpoints for different workflows - checkpoint1 = WorkflowCheckpoint(workflow_id="workflow-1") - checkpoint2 = WorkflowCheckpoint(workflow_id="workflow-1") - checkpoint3 = WorkflowCheckpoint(workflow_id="workflow-2") + checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1") + checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2") + checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3") - await storage.save_checkpoint(checkpoint1) - await storage.save_checkpoint(checkpoint2) - await storage.save_checkpoint(checkpoint3) + await storage.save(checkpoint1) + await storage.save(checkpoint2) + await storage.save(checkpoint3) - # Test list_checkpoint_ids for workflow-1 - workflow1_checkpoint_ids = await storage.list_checkpoint_ids("workflow-1") + # Test list_ids for workflow-1 + workflow1_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-1") assert len(workflow1_checkpoint_ids) == 2 assert checkpoint1.checkpoint_id in workflow1_checkpoint_ids assert checkpoint2.checkpoint_id in workflow1_checkpoint_ids - # Test list_checkpoints for workflow-1 (returns objects) - workflow1_checkpoints = await storage.list_checkpoints("workflow-1") + # Test list for workflow-1 (returns objects) + workflow1_checkpoints = await storage.list_checkpoints(workflow_name="workflow-1") assert len(workflow1_checkpoints) == 2 assert all(isinstance(cp, WorkflowCheckpoint) for cp in workflow1_checkpoints) assert {cp.checkpoint_id for cp in workflow1_checkpoints} == {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id} - # Test list_checkpoint_ids for workflow-2 - workflow2_checkpoint_ids = await storage.list_checkpoint_ids("workflow-2") + # Test list_ids for workflow-2 + workflow2_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-2") assert len(workflow2_checkpoint_ids) == 1 assert checkpoint3.checkpoint_id in workflow2_checkpoint_ids - # Test list_checkpoints for workflow-2 (returns objects) - workflow2_checkpoints = await storage.list_checkpoints("workflow-2") + # Test list for workflow-2 (returns objects) + workflow2_checkpoints = await storage.list_checkpoints(workflow_name="workflow-2") assert len(workflow2_checkpoints) == 1 assert workflow2_checkpoints[0].checkpoint_id == checkpoint3.checkpoint_id - # Test list_checkpoint_ids for non-existent workflow - empty_checkpoint_ids = await storage.list_checkpoint_ids("nonexistent-workflow") + # Test list_ids for non-existent workflow + empty_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="nonexistent-workflow") assert len(empty_checkpoint_ids) == 0 - # Test list_checkpoints for non-existent workflow - empty_checkpoints = await storage.list_checkpoints("nonexistent-workflow") + # Test list for non-existent workflow + empty_checkpoints = await storage.list_checkpoints(workflow_name="nonexistent-workflow") assert len(empty_checkpoints) == 0 - # Test list_checkpoint_ids without workflow filter (all checkpoints) - all_checkpoint_ids = await storage.list_checkpoint_ids() - assert len(all_checkpoint_ids) == 3 - expected_ids = {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id, checkpoint3.checkpoint_id} - assert expected_ids.issubset(set(all_checkpoint_ids)) - - # Test list_checkpoints without workflow filter (all checkpoints) - all_checkpoints = await storage.list_checkpoints() - assert len(all_checkpoints) == 3 - assert all(isinstance(cp, WorkflowCheckpoint) for cp in all_checkpoints) - async def test_memory_checkpoint_storage_delete(): storage = InMemoryCheckpointStorage() - checkpoint = WorkflowCheckpoint(workflow_id="test-workflow") + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") # Save checkpoint - await storage.save_checkpoint(checkpoint) - assert await storage.load_checkpoint(checkpoint.checkpoint_id) is not None + await storage.save(checkpoint) + assert await storage.load(checkpoint.checkpoint_id) is not None # Delete checkpoint - result = await storage.delete_checkpoint(checkpoint.checkpoint_id) + result = await storage.delete(checkpoint.checkpoint_id) assert result is True # Verify deletion - assert await storage.load_checkpoint(checkpoint.checkpoint_id) is None + with pytest.raises(WorkflowCheckpointException): + await storage.load(checkpoint.checkpoint_id) # Try to delete again - result = await storage.delete_checkpoint(checkpoint.checkpoint_id) + result = await storage.delete(checkpoint.checkpoint_id) assert result is False +async def test_memory_checkpoint_storage_get_latest(): + import asyncio + + storage = InMemoryCheckpointStorage() + + # Create checkpoints with small delays to ensure different timestamps + checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1") + await asyncio.sleep(0.01) + checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2") + await asyncio.sleep(0.01) + checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3") + + await storage.save(checkpoint1) + await storage.save(checkpoint2) + await storage.save(checkpoint3) + + # Test get_latest for workflow-1 + latest = await storage.get_latest(workflow_name="workflow-1") + assert latest is not None + assert latest.checkpoint_id == checkpoint2.checkpoint_id + + # Test get_latest for workflow-2 + latest2 = await storage.get_latest(workflow_name="workflow-2") + assert latest2 is not None + assert latest2.checkpoint_id == checkpoint3.checkpoint_id + + # Test get_latest for non-existent workflow + latest_none = await storage.get_latest(workflow_name="nonexistent-workflow") + assert latest_none is None + + +async def test_workflow_checkpoint_chaining_via_previous_checkpoint_id(): + """Test that consecutive checkpoints created by a workflow are properly chained via previous_checkpoint_id.""" + from typing_extensions import Never + + from agent_framework import WorkflowBuilder, WorkflowContext, handler + from agent_framework._workflows._executor import Executor + + class StartExecutor(Executor): + @handler + async def run(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message, target_id="middle") + + class MiddleExecutor(Executor): + @handler + async def process(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message + "-processed", target_id="finish") + + class FinishExecutor(Executor): + @handler + async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: + await ctx.yield_output(message + "-done") + + storage = InMemoryCheckpointStorage() + + start = StartExecutor(id="start") + middle = MiddleExecutor(id="middle") + finish = FinishExecutor(id="finish") + + workflow = ( + WorkflowBuilder(max_iterations=10, start_executor=start, checkpoint_storage=storage) + .add_edge(start, middle) + .add_edge(middle, finish) + .build() + ) + + # Run workflow - this creates checkpoints at each superstep + _ = [event async for event in workflow.run("hello", stream=True)] + + # Get all checkpoints sorted by timestamp + checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow.name), key=lambda c: c.timestamp) + + # Should have multiple checkpoints (one initial + one per superstep) + assert len(checkpoints) >= 2, f"Expected at least 2 checkpoints, got {len(checkpoints)}" + + # Verify chaining: first checkpoint has no previous + assert checkpoints[0].previous_checkpoint_id is None + + # Subsequent checkpoints should chain to the previous one + for i in range(1, len(checkpoints)): + assert checkpoints[i].previous_checkpoint_id == checkpoints[i - 1].checkpoint_id, ( + f"Checkpoint {i} should chain to checkpoint {i - 1}" + ) + + +async def test_memory_checkpoint_storage_roundtrip_json_native_types(): + """Test that JSON-native types (str, int, float, bool, None) roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "string": "hello world", + "integer": 42, + "negative_int": -100, + "float": 3.14159, + "negative_float": -2.71828, + "bool_true": True, + "bool_false": False, + "null_value": None, + "zero": 0, + "empty_string": "", + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state == checkpoint.state + + +async def test_memory_checkpoint_storage_roundtrip_datetime(): + """Test that datetime objects roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + now = datetime.now(timezone.utc) + specific_datetime = datetime(2025, 6, 15, 10, 30, 45, 123456, tzinfo=timezone.utc) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "current_time": now, + "specific_time": specific_datetime, + "nested": {"created_at": now, "updated_at": specific_datetime}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["current_time"] == now + assert loaded.state["specific_time"] == specific_datetime + assert loaded.state["nested"]["created_at"] == now + assert loaded.state["nested"]["updated_at"] == specific_datetime + + +async def test_memory_checkpoint_storage_roundtrip_dataclass(): + """Test that dataclass objects roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"]) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "custom_data": custom_obj, + "nested": {"inner_data": custom_obj}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["custom_data"] == custom_obj + assert loaded.state["custom_data"].name == "test" + assert loaded.state["custom_data"].value == 42 + assert loaded.state["custom_data"].tags == ["a", "b", "c"] + assert loaded.state["nested"]["inner_data"] == custom_obj + assert isinstance(loaded.state["custom_data"], _TestCustomData) + + +async def test_memory_checkpoint_storage_roundtrip_tuple_and_set(): + """Test that tuples and frozensets roundtrip correctly (type preserved in memory).""" + storage = InMemoryCheckpointStorage() + + original_tuple = (1, "two", 3.0, None) + original_frozenset = frozenset({1, 2, 3}) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "my_tuple": original_tuple, + "my_frozenset": original_frozenset, + "nested_tuple": {"inner": (10, 20, 30)}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # In-memory storage preserves exact types (no JSON serialization) + assert loaded.state["my_tuple"] == original_tuple + assert isinstance(loaded.state["my_tuple"], tuple) + assert loaded.state["my_frozenset"] == original_frozenset + assert isinstance(loaded.state["my_frozenset"], frozenset) + assert loaded.state["nested_tuple"]["inner"] == (10, 20, 30) + assert isinstance(loaded.state["nested_tuple"]["inner"], tuple) + + +async def test_memory_checkpoint_storage_roundtrip_complex_nested_structures(): + """Test complex nested structures with mixed types roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + # Create complex nested structure mixing JSON-native and non-native types + complex_state = { + "level1": { + "level2": { + "level3": { + "deep_string": "hello", + "deep_int": 123, + "deep_datetime": datetime(2025, 1, 1, tzinfo=timezone.utc), + "deep_tuple": (1, 2, 3), + } + }, + "list_of_dicts": [ + {"a": 1, "b": datetime(2025, 2, 1, tzinfo=timezone.utc)}, + {"c": 2, "d": (4, 5, 6)}, + ], + }, + "mixed_list": [ + "string", + 42, + 3.14, + True, + None, + datetime(2025, 3, 1, tzinfo=timezone.utc), + (7, 8, 9), + ], + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state=complex_state, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify deep nested values + assert loaded.state["level1"]["level2"]["level3"]["deep_string"] == "hello" + assert loaded.state["level1"]["level2"]["level3"]["deep_int"] == 123 + assert loaded.state["level1"]["level2"]["level3"]["deep_datetime"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded.state["level1"]["level2"]["level3"]["deep_tuple"] == (1, 2, 3) + assert isinstance(loaded.state["level1"]["level2"]["level3"]["deep_tuple"], tuple) + + # Verify list of dicts + assert loaded.state["level1"]["list_of_dicts"][0]["a"] == 1 + assert loaded.state["level1"]["list_of_dicts"][0]["b"] == datetime(2025, 2, 1, tzinfo=timezone.utc) + assert loaded.state["level1"]["list_of_dicts"][1]["d"] == (4, 5, 6) + assert isinstance(loaded.state["level1"]["list_of_dicts"][1]["d"], tuple) + + # Verify mixed list with correct types + assert loaded.state["mixed_list"][0] == "string" + assert loaded.state["mixed_list"][1] == 42 + assert loaded.state["mixed_list"][5] == datetime(2025, 3, 1, tzinfo=timezone.utc) + assert loaded.state["mixed_list"][6] == (7, 8, 9) + assert isinstance(loaded.state["mixed_list"][6], tuple) + + +async def test_memory_checkpoint_storage_roundtrip_messages_with_complex_data(): + """Test that messages dict with Message objects roundtrips correctly.""" + storage = InMemoryCheckpointStorage() + + msg1 = WorkflowMessage( + data={"text": "hello", "timestamp": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + source_id="source", + target_id="target", + ) + msg2 = WorkflowMessage( + data=(1, 2, 3), + source_id="s2", + target_id=None, + ) + msg3 = WorkflowMessage( + data="simple string", + source_id="s3", + target_id="t3", + ) + + messages = { + "executor1": [msg1, msg2], + "executor2": [msg3], + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + messages=messages, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify messages structure and types + assert len(loaded.messages["executor1"]) == 2 + loaded_msg1 = loaded.messages["executor1"][0] + loaded_msg2 = loaded.messages["executor1"][1] + loaded_msg3 = loaded.messages["executor2"][0] + + # Verify Message type is preserved + assert isinstance(loaded_msg1, WorkflowMessage) + assert isinstance(loaded_msg2, WorkflowMessage) + assert isinstance(loaded_msg3, WorkflowMessage) + + # Verify Message fields + assert loaded_msg1.data["text"] == "hello" + assert loaded_msg1.data["timestamp"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded_msg1.source_id == "source" + assert loaded_msg1.target_id == "target" + + assert loaded_msg2.data == (1, 2, 3) + assert isinstance(loaded_msg2.data, tuple) + assert loaded_msg2.source_id == "s2" + assert loaded_msg2.target_id is None + + assert loaded_msg3.data == "simple string" + assert loaded_msg3.source_id == "s3" + assert loaded_msg3.target_id == "t3" + + +async def test_memory_checkpoint_storage_roundtrip_pending_request_info_events(): + """Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + # Create request_info events using the proper WorkflowEvent factory + event1 = WorkflowEvent.request_info( + request_id="req123", + source_executor_id="executor1", + request_data="What is your name?", + response_type=str, + ) + event2 = WorkflowEvent.request_info( + request_id="req456", + source_executor_id="executor2", + request_data=_TestToolApprovalRequest( + tool_name="search", + arguments={"query": "test"}, + timestamp=datetime(2025, 1, 1, tzinfo=timezone.utc), + ), + response_type=bool, + ) + + pending_events = { + "req123": event1, + "req456": event2, + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + pending_request_info_events=pending_events, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify WorkflowEvent type is preserved + loaded_event1 = loaded.pending_request_info_events["req123"] + loaded_event2 = loaded.pending_request_info_events["req456"] + + assert isinstance(loaded_event1, WorkflowEvent) + assert isinstance(loaded_event2, WorkflowEvent) + + # Verify event1 fields + assert loaded_event1.type == "request_info" + assert loaded_event1.request_id == "req123" + assert loaded_event1.source_executor_id == "executor1" + assert loaded_event1.data == "What is your name?" + assert loaded_event1.response_type is str + + # Verify event2 fields with complex data + assert loaded_event2.type == "request_info" + assert loaded_event2.request_id == "req456" + assert loaded_event2.source_executor_id == "executor2" + assert isinstance(loaded_event2.data, _TestToolApprovalRequest) + assert loaded_event2.data.tool_name == "search" + assert loaded_event2.data.arguments == {"query": "test"} + assert loaded_event2.data.timestamp == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded_event2.response_type is bool + + +async def test_memory_checkpoint_storage_roundtrip_full_checkpoint(): + """Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types.""" + storage = InMemoryCheckpointStorage() + + # Create proper WorkflowMessage objects + msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t") + msg2 = WorkflowMessage(data=datetime(2025, 1, 1, tzinfo=timezone.utc), source_id="a", target_id="b") + + # Create proper WorkflowEvent for pending request + pending_event = WorkflowEvent.request_info( + request_id="req1", + source_executor_id="exec1", + request_data=_TestApprovalRequest(action="approve", params=(1, 2, 3)), + response_type=bool, + ) + + checkpoint = WorkflowCheckpoint( + checkpoint_id="full-test-checkpoint", + workflow_name="comprehensive-test", + graph_signature_hash="hash-abc123", + previous_checkpoint_id="previous-checkpoint-id", + timestamp=datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc).isoformat(), + messages={ + "exec1": [msg1], + "exec2": [msg2], + }, + state={ + "user_data": {"name": "test", "created": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + "_executor_state": { + "exec1": _TestExecutorState(counter=5, history=["a", "b", "c"]), + }, + }, + pending_request_info_events={ + "req1": pending_event, + }, + iteration_count=10, + metadata={ + "superstep": 5, + "started_at": datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc), + }, + version="1.0", + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify all scalar fields + assert loaded.checkpoint_id == checkpoint.checkpoint_id + assert loaded.workflow_name == checkpoint.workflow_name + assert loaded.graph_signature_hash == checkpoint.graph_signature_hash + assert loaded.previous_checkpoint_id == checkpoint.previous_checkpoint_id + assert loaded.timestamp == checkpoint.timestamp + assert loaded.iteration_count == checkpoint.iteration_count + assert loaded.version == checkpoint.version + + # Verify complex nested state data + assert loaded.state["user_data"]["created"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded.state["_executor_state"]["exec1"].counter == 5 + assert loaded.state["_executor_state"]["exec1"].history == ["a", "b", "c"] + assert isinstance(loaded.state["_executor_state"]["exec1"], _TestExecutorState) + + # Verify messages are proper Message objects + loaded_msg1 = loaded.messages["exec1"][0] + loaded_msg2 = loaded.messages["exec2"][0] + assert isinstance(loaded_msg1, WorkflowMessage) + assert isinstance(loaded_msg2, WorkflowMessage) + assert loaded_msg1.data == "msg1" + assert loaded_msg1.source_id == "s" + assert loaded_msg2.data == datetime(2025, 1, 1, tzinfo=timezone.utc) + + # Verify pending events are proper WorkflowEvent objects + loaded_event = loaded.pending_request_info_events["req1"] + assert isinstance(loaded_event, WorkflowEvent) + assert loaded_event.type == "request_info" + assert loaded_event.request_id == "req1" + assert isinstance(loaded_event.data, _TestApprovalRequest) + assert loaded_event.data.params == (1, 2, 3) + + # Verify metadata + assert loaded.metadata["superstep"] == 5 + assert loaded.metadata["started_at"] == datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc) + + +async def test_memory_checkpoint_storage_roundtrip_bytes(): + """Test that bytes objects roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + binary_data = b"\x00\x01\x02\xff\xfe\xfd" + unicode_bytes = "Hello 世界".encode() + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "binary_data": binary_data, + "unicode_bytes": unicode_bytes, + "nested": {"inner_bytes": binary_data}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["binary_data"] == binary_data + assert loaded.state["unicode_bytes"] == unicode_bytes + assert loaded.state["nested"]["inner_bytes"] == binary_data + assert isinstance(loaded.state["binary_data"], bytes) + + +async def test_memory_checkpoint_storage_roundtrip_empty_collections(): + """Test that empty collections roundtrip correctly (types preserved in memory).""" + storage = InMemoryCheckpointStorage() + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "empty_dict": {}, + "empty_list": [], + "empty_tuple": (), + "nested_empty": {"inner_dict": {}, "inner_list": []}, + }, + messages={}, + pending_request_info_events={}, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["empty_dict"] == {} + assert loaded.state["empty_list"] == [] + # In-memory storage preserves exact types (no JSON serialization) + assert loaded.state["empty_tuple"] == () + assert isinstance(loaded.state["empty_tuple"], tuple) + assert loaded.state["nested_empty"]["inner_dict"] == {} + assert loaded.messages == {} + assert loaded.pending_request_info_events == {} + + +# endregion + +# region FileCheckpointStorage + + async def test_file_checkpoint_storage_save_and_load(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) checkpoint = WorkflowCheckpoint( - workflow_id="test-workflow", + workflow_name="test-workflow", + graph_signature_hash="test-hash", messages={"executor1": [{"data": "hello", "source_id": "test", "target_id": None}]}, state={"key": "value"}, pending_request_info_events={"req123": {"data": "test"}}, ) # Save checkpoint - saved_id = await storage.save_checkpoint(checkpoint) + saved_id = await storage.save(checkpoint) assert saved_id == checkpoint.checkpoint_id # Verify file was created @@ -172,10 +790,11 @@ async def test_file_checkpoint_storage_save_and_load(): assert file_path.exists() # Load checkpoint - loaded_checkpoint = await storage.load_checkpoint(checkpoint.checkpoint_id) + loaded_checkpoint = await storage.load(checkpoint.checkpoint_id) assert loaded_checkpoint is not None assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id - assert loaded_checkpoint.workflow_id == checkpoint.workflow_id + assert loaded_checkpoint.workflow_name == checkpoint.workflow_name + assert loaded_checkpoint.graph_signature_hash == checkpoint.graph_signature_hash assert loaded_checkpoint.messages == checkpoint.messages assert loaded_checkpoint.state == checkpoint.state assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events @@ -185,72 +804,64 @@ async def test_file_checkpoint_storage_load_nonexistent(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) - result = await storage.load_checkpoint("nonexistent-id") - assert result is None + with pytest.raises(WorkflowCheckpointException): + await storage.load("nonexistent-id") -async def test_file_checkpoint_storage_list_checkpoints(): +async def test_file_checkpoint_storage_list(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) # Create checkpoints for different workflows - checkpoint1 = WorkflowCheckpoint(workflow_id="workflow-1") - checkpoint2 = WorkflowCheckpoint(workflow_id="workflow-1") - checkpoint3 = WorkflowCheckpoint(workflow_id="workflow-2") + checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1") + checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2") + checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3") - await storage.save_checkpoint(checkpoint1) - await storage.save_checkpoint(checkpoint2) - await storage.save_checkpoint(checkpoint3) + await storage.save(checkpoint1) + await storage.save(checkpoint2) + await storage.save(checkpoint3) - # Test list_checkpoint_ids for workflow-1 - workflow1_checkpoint_ids = await storage.list_checkpoint_ids("workflow-1") + # Test list_ids for workflow-1 + workflow1_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-1") assert len(workflow1_checkpoint_ids) == 2 assert checkpoint1.checkpoint_id in workflow1_checkpoint_ids assert checkpoint2.checkpoint_id in workflow1_checkpoint_ids - # Test list_checkpoints for workflow-1 (returns objects) - workflow1_checkpoints = await storage.list_checkpoints("workflow-1") + # Test list for workflow-1 (returns objects) + workflow1_checkpoints = await storage.list_checkpoints(workflow_name="workflow-1") assert len(workflow1_checkpoints) == 2 assert all(isinstance(cp, WorkflowCheckpoint) for cp in workflow1_checkpoints) checkpoint_ids = {cp.checkpoint_id for cp in workflow1_checkpoints} assert checkpoint_ids == {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id} - # Test list_checkpoint_ids for workflow-2 - workflow2_checkpoint_ids = await storage.list_checkpoint_ids("workflow-2") + # Test list_ids for workflow-2 + workflow2_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-2") assert len(workflow2_checkpoint_ids) == 1 assert checkpoint3.checkpoint_id in workflow2_checkpoint_ids - # Test list_checkpoints for workflow-2 (returns objects) - workflow2_checkpoints = await storage.list_checkpoints("workflow-2") + # Test list for workflow-2 (returns objects) + workflow2_checkpoints = await storage.list_checkpoints(workflow_name="workflow-2") assert len(workflow2_checkpoints) == 1 assert workflow2_checkpoints[0].checkpoint_id == checkpoint3.checkpoint_id - # Test list all checkpoints - all_checkpoint_ids = await storage.list_checkpoint_ids() - assert len(all_checkpoint_ids) == 3 - - all_checkpoints = await storage.list_checkpoints() - assert len(all_checkpoints) == 3 - assert all(isinstance(cp, WorkflowCheckpoint) for cp in all_checkpoints) - async def test_file_checkpoint_storage_delete(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) - checkpoint = WorkflowCheckpoint(workflow_id="test-workflow") + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") # Save checkpoint - await storage.save_checkpoint(checkpoint) + await storage.save(checkpoint) file_path = Path(temp_dir) / f"{checkpoint.checkpoint_id}.json" assert file_path.exists() # Delete checkpoint - result = await storage.delete_checkpoint(checkpoint.checkpoint_id) + result = await storage.delete(checkpoint.checkpoint_id) assert result is True assert not file_path.exists() # Try to delete again - result = await storage.delete_checkpoint(checkpoint.checkpoint_id) + result = await storage.delete(checkpoint.checkpoint_id) assert result is False @@ -264,8 +875,8 @@ async def test_file_checkpoint_storage_directory_creation(): assert nested_path.is_dir() # Should be able to save checkpoints - checkpoint = WorkflowCheckpoint(workflow_id="test") - await storage.save_checkpoint(checkpoint) + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") + await storage.save(checkpoint) file_path = nested_path / f"{checkpoint.checkpoint_id}.json" assert file_path.exists() @@ -280,8 +891,8 @@ async def test_file_checkpoint_storage_corrupted_file(): with open(corrupted_file, "w") as f: # noqa: ASYNC230 f.write("{ invalid json }") - # list_checkpoints should handle the corrupted file gracefully - checkpoints = await storage.list_checkpoints("any-workflow") + # list should handle the corrupted file gracefully + checkpoints = await storage.list_checkpoints(workflow_name="any-workflow") assert checkpoints == [] @@ -291,15 +902,16 @@ async def test_file_checkpoint_storage_json_serialization(): # Create checkpoint with complex nested data checkpoint = WorkflowCheckpoint( - workflow_id="complex-workflow", + workflow_name="test-workflow", + graph_signature_hash="test-hash", messages={"executor1": [{"data": {"nested": {"value": 42}}, "source_id": "test", "target_id": None}]}, state={"list": [1, 2, 3], "dict": {"a": "b", "c": {"d": "e"}}, "bool": True, "null": None}, pending_request_info_events={"req123": {"data": "test"}}, ) # Save and load - await storage.save_checkpoint(checkpoint) - loaded = await storage.load_checkpoint(checkpoint.checkpoint_id) + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) assert loaded is not None assert loaded.messages == checkpoint.messages @@ -317,22 +929,513 @@ async def test_file_checkpoint_storage_json_serialization(): assert data["pending_request_info_events"]["req123"]["data"] == "test" -def test_checkpoint_storage_protocol_compliance(): - # This test ensures both implementations have all required methods - memory_storage = InMemoryCheckpointStorage() +async def test_file_checkpoint_storage_get_latest(): + import asyncio with tempfile.TemporaryDirectory() as temp_dir: - file_storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage(temp_dir) - for storage in [memory_storage, file_storage]: - # Test that all protocol methods exist and are callable - assert hasattr(storage, "save_checkpoint") - assert callable(storage.save_checkpoint) - assert hasattr(storage, "load_checkpoint") - assert callable(storage.load_checkpoint) - assert hasattr(storage, "list_checkpoint_ids") - assert callable(storage.list_checkpoint_ids) - assert hasattr(storage, "list_checkpoints") - assert callable(storage.list_checkpoints) - assert hasattr(storage, "delete_checkpoint") - assert callable(storage.delete_checkpoint) + # Create checkpoints with small delays to ensure different timestamps + checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1") + await asyncio.sleep(0.01) + checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2") + await asyncio.sleep(0.01) + checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3") + + await storage.save(checkpoint1) + await storage.save(checkpoint2) + await storage.save(checkpoint3) + + # Test get_latest for workflow-1 + latest = await storage.get_latest(workflow_name="workflow-1") + assert latest is not None + assert latest.checkpoint_id == checkpoint2.checkpoint_id + + # Test get_latest for workflow-2 + latest2 = await storage.get_latest(workflow_name="workflow-2") + assert latest2 is not None + assert latest2.checkpoint_id == checkpoint3.checkpoint_id + + # Test get_latest for non-existent workflow + latest_none = await storage.get_latest(workflow_name="nonexistent-workflow") + assert latest_none is None + + +async def test_file_checkpoint_storage_list_ids_corrupted_file(): + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Create a valid checkpoint first + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") + await storage.save(checkpoint) + + # Create a corrupted JSON file + corrupted_file = Path(temp_dir) / "corrupted.json" + with open(corrupted_file, "w") as f: # noqa: ASYNC230 + f.write("{ invalid json }") + + # list_ids should handle the corrupted file gracefully + checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="test-workflow") + assert len(checkpoint_ids) == 1 + assert checkpoint.checkpoint_id in checkpoint_ids + + +async def test_file_checkpoint_storage_list_ids_empty(): + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Test list_ids on empty storage + checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="any-workflow") + assert checkpoint_ids == [] + + +async def test_file_checkpoint_storage_roundtrip_json_native_types(): + """Test that JSON-native types (str, int, float, bool, None) roundtrip correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "string": "hello world", + "integer": 42, + "negative_int": -100, + "float": 3.14159, + "negative_float": -2.71828, + "bool_true": True, + "bool_false": False, + "null_value": None, + "zero": 0, + "empty_string": "", + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state == checkpoint.state + + +async def test_file_checkpoint_storage_roundtrip_datetime(): + """Test that datetime objects roundtrip correctly via pickle encoding.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + now = datetime.now(timezone.utc) + specific_datetime = datetime(2025, 6, 15, 10, 30, 45, 123456, tzinfo=timezone.utc) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "current_time": now, + "specific_time": specific_datetime, + "nested": {"created_at": now, "updated_at": specific_datetime}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["current_time"] == now + assert loaded.state["specific_time"] == specific_datetime + assert loaded.state["nested"]["created_at"] == now + assert loaded.state["nested"]["updated_at"] == specific_datetime + + +async def test_file_checkpoint_storage_roundtrip_dataclass(): + """Test that dataclass objects roundtrip correctly via pickle encoding.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"]) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "custom_data": custom_obj, + "nested": {"inner_data": custom_obj}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["custom_data"] == custom_obj + assert loaded.state["custom_data"].name == "test" + assert loaded.state["custom_data"].value == 42 + assert loaded.state["custom_data"].tags == ["a", "b", "c"] + assert loaded.state["nested"]["inner_data"] == custom_obj + assert isinstance(loaded.state["custom_data"], _TestCustomData) + + +async def test_file_checkpoint_storage_roundtrip_tuple_and_set(): + """Test tuple/frozenset encoding behavior. + + Tuples, sets, and frozensets are pickled to preserve their type through + the encode/decode roundtrip. + """ + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + original_tuple = (1, "two", 3.0, None) + original_frozenset = frozenset({1, 2, 3}) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "my_tuple": original_tuple, + "my_frozenset": original_frozenset, + "nested_tuple": {"inner": (10, 20, 30)}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Tuples preserve their type through roundtrip + assert loaded.state["my_tuple"] == original_tuple + assert isinstance(loaded.state["my_tuple"], tuple) + + # Frozensets are pickled and preserve their type + assert loaded.state["my_frozenset"] == original_frozenset + assert isinstance(loaded.state["my_frozenset"], frozenset) + + # Nested tuples also preserve their type + assert loaded.state["nested_tuple"]["inner"] == (10, 20, 30) + assert isinstance(loaded.state["nested_tuple"]["inner"], tuple) + + +async def test_file_checkpoint_storage_roundtrip_complex_nested_structures(): + """Test complex nested structures with mixed types roundtrip correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Create complex nested structure mixing JSON-native and non-native types + complex_state = { + "level1": { + "level2": { + "level3": { + "deep_string": "hello", + "deep_int": 123, + "deep_datetime": datetime(2025, 1, 1, tzinfo=timezone.utc), + "deep_tuple": (1, 2, 3), + } + }, + "list_of_dicts": [ + {"a": 1, "b": datetime(2025, 2, 1, tzinfo=timezone.utc)}, + {"c": 2, "d": (4, 5, 6)}, + ], + }, + "mixed_list": [ + "string", + 42, + 3.14, + True, + None, + datetime(2025, 3, 1, tzinfo=timezone.utc), + (7, 8, 9), + ], + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state=complex_state, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify deep nested values + assert loaded.state["level1"]["level2"]["level3"]["deep_string"] == "hello" + assert loaded.state["level1"]["level2"]["level3"]["deep_int"] == 123 + assert loaded.state["level1"]["level2"]["level3"]["deep_datetime"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + # Tuples preserve their type through roundtrip + assert loaded.state["level1"]["level2"]["level3"]["deep_tuple"] == (1, 2, 3) + + # Verify list of dicts + assert loaded.state["level1"]["list_of_dicts"][0]["a"] == 1 + assert loaded.state["level1"]["list_of_dicts"][0]["b"] == datetime(2025, 2, 1, tzinfo=timezone.utc) + # Tuples preserve their type through roundtrip + assert loaded.state["level1"]["list_of_dicts"][1]["d"] == (4, 5, 6) + + # Verify mixed list with correct types + assert loaded.state["mixed_list"][0] == "string" + assert loaded.state["mixed_list"][1] == 42 + assert loaded.state["mixed_list"][5] == datetime(2025, 3, 1, tzinfo=timezone.utc) + # Tuples preserve their type through roundtrip + assert loaded.state["mixed_list"][6] == (7, 8, 9) + assert isinstance(loaded.state["mixed_list"][6], tuple) + + +async def test_file_checkpoint_storage_roundtrip_messages_with_complex_data(): + """Test that messages dict with Message objects roundtrips correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + msg1 = WorkflowMessage( + data={"text": "hello", "timestamp": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + source_id="source", + target_id="target", + ) + msg2 = WorkflowMessage( + data=(1, 2, 3), + source_id="s2", + target_id=None, + ) + msg3 = WorkflowMessage( + data="simple string", + source_id="s3", + target_id="t3", + ) + + messages = { + "executor1": [msg1, msg2], + "executor2": [msg3], + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + messages=messages, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify messages structure and types + assert len(loaded.messages["executor1"]) == 2 + loaded_msg1 = loaded.messages["executor1"][0] + loaded_msg2 = loaded.messages["executor1"][1] + loaded_msg3 = loaded.messages["executor2"][0] + + # Verify WorkflowMessage type is preserved + assert isinstance(loaded_msg1, WorkflowMessage) + assert isinstance(loaded_msg2, WorkflowMessage) + assert isinstance(loaded_msg3, WorkflowMessage) + + # Verify WorkflowMessage fields + assert loaded_msg1.data["text"] == "hello" + assert loaded_msg1.data["timestamp"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded_msg1.source_id == "source" + assert loaded_msg1.target_id == "target" + + assert loaded_msg2.data == (1, 2, 3) + assert isinstance(loaded_msg2.data, tuple) + assert loaded_msg2.source_id == "s2" + assert loaded_msg2.target_id is None + + assert loaded_msg3.data == "simple string" + assert loaded_msg3.source_id == "s3" + assert loaded_msg3.target_id == "t3" + + +async def test_file_checkpoint_storage_roundtrip_pending_request_info_events(): + """Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Create request_info events using the proper WorkflowEvent factory + event1 = WorkflowEvent.request_info( + request_id="req123", + source_executor_id="executor1", + request_data="What is your name?", + response_type=str, + ) + event2 = WorkflowEvent.request_info( + request_id="req456", + source_executor_id="executor2", + request_data=_TestToolApprovalRequest( + tool_name="search", + arguments={"query": "test"}, + timestamp=datetime(2025, 1, 1, tzinfo=timezone.utc), + ), + response_type=bool, + ) + + pending_events = { + "req123": event1, + "req456": event2, + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + pending_request_info_events=pending_events, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify WorkflowEvent type is preserved + loaded_event1 = loaded.pending_request_info_events["req123"] + loaded_event2 = loaded.pending_request_info_events["req456"] + + assert isinstance(loaded_event1, WorkflowEvent) + assert isinstance(loaded_event2, WorkflowEvent) + + # Verify event1 fields + assert loaded_event1.type == "request_info" + assert loaded_event1.request_id == "req123" + assert loaded_event1.source_executor_id == "executor1" + assert loaded_event1.data == "What is your name?" + assert loaded_event1.response_type is str + + # Verify event2 fields with complex data + assert loaded_event2.type == "request_info" + assert loaded_event2.request_id == "req456" + assert loaded_event2.source_executor_id == "executor2" + assert isinstance(loaded_event2.data, _TestToolApprovalRequest) + assert loaded_event2.data.tool_name == "search" + assert loaded_event2.data.arguments == {"query": "test"} + assert loaded_event2.data.timestamp == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded_event2.response_type is bool + + +async def test_file_checkpoint_storage_roundtrip_full_checkpoint(): + """Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Create proper WorkflowMessage objects + msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t") + msg2 = WorkflowMessage(data=datetime(2025, 1, 1, tzinfo=timezone.utc), source_id="a", target_id="b") + + # Create proper WorkflowEvent for pending request + pending_event = WorkflowEvent.request_info( + request_id="req1", + source_executor_id="exec1", + request_data=_TestApprovalRequest(action="approve", params=(1, 2, 3)), + response_type=bool, + ) + + checkpoint = WorkflowCheckpoint( + checkpoint_id="full-test-checkpoint", + workflow_name="comprehensive-test", + graph_signature_hash="hash-abc123", + previous_checkpoint_id="previous-checkpoint-id", + timestamp=datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc).isoformat(), + messages={ + "exec1": [msg1], + "exec2": [msg2], + }, + state={ + "user_data": {"name": "test", "created": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + "_executor_state": { + "exec1": _TestExecutorState(counter=5, history=["a", "b", "c"]), + }, + }, + pending_request_info_events={ + "req1": pending_event, + }, + iteration_count=10, + metadata={ + "superstep": 5, + "started_at": datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc), + }, + version="1.0", + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify all scalar fields + assert loaded.checkpoint_id == checkpoint.checkpoint_id + assert loaded.workflow_name == checkpoint.workflow_name + assert loaded.graph_signature_hash == checkpoint.graph_signature_hash + assert loaded.previous_checkpoint_id == checkpoint.previous_checkpoint_id + assert loaded.timestamp == checkpoint.timestamp + assert loaded.iteration_count == checkpoint.iteration_count + assert loaded.version == checkpoint.version + + # Verify complex nested state data + assert loaded.state["user_data"]["created"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded.state["_executor_state"]["exec1"].counter == 5 + assert loaded.state["_executor_state"]["exec1"].history == ["a", "b", "c"] + assert isinstance(loaded.state["_executor_state"]["exec1"], _TestExecutorState) + + # Verify messages are proper Message objects + loaded_msg1 = loaded.messages["exec1"][0] + loaded_msg2 = loaded.messages["exec2"][0] + assert isinstance(loaded_msg1, WorkflowMessage) + assert isinstance(loaded_msg2, WorkflowMessage) + assert loaded_msg1.data == "msg1" + assert loaded_msg1.source_id == "s" + assert loaded_msg2.data == datetime(2025, 1, 1, tzinfo=timezone.utc) + + # Verify pending events are proper WorkflowEvent objects + loaded_event = loaded.pending_request_info_events["req1"] + assert isinstance(loaded_event, WorkflowEvent) + assert loaded_event.type == "request_info" + assert loaded_event.request_id == "req1" + assert isinstance(loaded_event.data, _TestApprovalRequest) + assert loaded_event.data.params == (1, 2, 3) + + # Verify metadata + assert loaded.metadata["superstep"] == 5 + assert loaded.metadata["started_at"] == datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc) + + +async def test_file_checkpoint_storage_roundtrip_bytes(): + """Test that bytes objects roundtrip correctly via pickle encoding.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + binary_data = b"\x00\x01\x02\xff\xfe\xfd" + unicode_bytes = "Hello 世界".encode() + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "binary_data": binary_data, + "unicode_bytes": unicode_bytes, + "nested": {"inner_bytes": binary_data}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["binary_data"] == binary_data + assert loaded.state["unicode_bytes"] == unicode_bytes + assert loaded.state["nested"]["inner_bytes"] == binary_data + assert isinstance(loaded.state["binary_data"], bytes) + + +async def test_file_checkpoint_storage_roundtrip_empty_collections(): + """Test that empty collections roundtrip correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "empty_dict": {}, + "empty_list": [], + "empty_tuple": (), + "nested_empty": {"inner_dict": {}, "inner_list": []}, + }, + messages={}, + pending_request_info_events={}, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["empty_dict"] == {} + assert loaded.state["empty_list"] == [] + # Empty tuples preserve their type through roundtrip + assert loaded.state["empty_tuple"] == () + assert isinstance(loaded.state["empty_tuple"], tuple) + assert loaded.state["nested_empty"]["inner_dict"] == {} + assert loaded.messages == {} + assert loaded.pending_request_info_events == {} + + +# endregion diff --git a/python/packages/core/tests/workflow/test_checkpoint_decode.py b/python/packages/core/tests/workflow/test_checkpoint_decode.py index 431c70cc3c..5ef9bc480f 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_decode.py +++ b/python/packages/core/tests/workflow/test_checkpoint_decode.py @@ -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", - } - encoded = encode_checkpoint_value(dict_with_marker_only) - assert MODEL_MARKER in encoded - assert "other_key" in encoded +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_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 +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_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", +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_both) - assert MODEL_MARKER in encoded - assert "value" 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" -class NotADataclass: + +# --- Tests for decode primitives --- + + +def test_decode_string() -> None: + """Test decoding a string passes through unchanged.""" + assert decode_checkpoint_value("hello") == "hello" + + +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") -class NotAModel: - """A regular class that does not support the model protocol.""" + encoded = encode_checkpoint_value(original) + decoded = cast(NotADataclass, decode_checkpoint_value(encoded)) - def __init__(self, value: str) -> None: - self.value = value + assert isinstance(decoded, NotADataclass) + assert decoded.value == "test_value" - def get_value(self) -> str: - return self.value +def test_roundtrip_tuple() -> None: + """Test that tuples preserve their type through encode/decode roundtrip.""" + original = (1, "two", 3.0) -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"}, - } + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) - decoded = decode_checkpoint_value(fake_payload) + assert isinstance(decoded, tuple) + assert decoded == original - # Should return the raw decoded value, not an instance of NotADataclass - assert isinstance(decoded, dict) - assert decoded["value"] == "test_value" +def test_roundtrip_set() -> None: + """Test that sets preserve their type through encode/decode roundtrip.""" + original = {1, 2, 3} -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"}, - } + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) - decoded = decode_checkpoint_value(fake_payload) + assert isinstance(decoded, set) + assert decoded == original - # Should return the raw decoded value, not an instance of NotAModel - assert isinstance(decoded, dict) - assert decoded["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"} -def test_encode_allows_nested_dict_with_marker_keys() -> None: - """Test that encoding allows nested dicts containing marker patterns. + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) - 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"}, - } - } + assert isinstance(decoded["items"], tuple) + assert decoded["items"] == (1, 2, 3) + assert decoded["name"] == "test" - encoded = encode_checkpoint_value(nested_data) - assert "outer" in encoded - assert MODEL_MARKER in encoded["outer"] + +def test_roundtrip_set_in_list() -> None: + """Test that sets nested inside lists preserve their type.""" + original = [{"tags": {1, 2, 3}}] + + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) + + assert isinstance(decoded[0]["tags"], set) + assert decoded[0]["tags"] == {1, 2, 3} diff --git a/python/packages/core/tests/workflow/test_checkpoint_encode.py b/python/packages/core/tests/workflow/test_checkpoint_encode.py index 3f4db1f864..68ec1ac4e3 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_encode.py +++ b/python/packages/core/tests/workflow/test_checkpoint_encode.py @@ -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 @@ def from_dict(cls, d: dict[str, Any]) -> "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 @@ def __str__(self) -> str: 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"} - - -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"] - + assert _PICKLE_MARKER in result -# --- 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 - - -# --- Tests for cycle detection --- - - -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 - + assert isinstance(result, dict) + assert _PICKLE_MARKER in result -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 - result = encode_checkpoint_value(data) - assert result[0] == 1 - assert result[1] == 2 - assert result[2] == _CYCLE_SENTINEL +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 -# --- 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. +# --- Tests for type marker --- -def test_encode_allows_dict_with_model_marker_and_value() -> None: - """Test that encoding a dict with MODEL_MARKER and 'value' is allowed. - Security is enforced at deserialization time, not serialization time. - """ - data = { - MODEL_MARKER: "some.module:SomeClass", - "value": {"data": "test"}, - } - result = encode_checkpoint_value(data) - assert MODEL_MARKER in result - assert "value" in result +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) + type_key = result[_TYPE_MARKER] + assert "SimpleDataclass" in type_key -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_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_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"] +# --- Tests for JSON serializability --- -def test_encode_allows_marker_without_value() -> None: - """Test that a dict with marker key but without 'value' key is allowed.""" +def test_encode_result_is_json_serializable() -> None: + """Test that encoded output is fully JSON-serializable.""" data = { - MODEL_MARKER: "some.module:SomeClass", - "other_key": "allowed", + "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 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 == "": - found_max_depth = True - break - else: - break - - assert found_max_depth, "Expected 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" diff --git a/python/packages/core/tests/workflow/test_checkpoint_validation.py b/python/packages/core/tests/workflow/test_checkpoint_validation.py index 17175451ce..a9c748a324 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_validation.py +++ b/python/packages/core/tests/workflow/test_checkpoint_validation.py @@ -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] diff --git a/python/packages/core/tests/workflow/test_request_info_and_response.py b/python/packages/core/tests/workflow/test_request_info_and_response.py index b62bfafb7c..05a7ed1ec5 100644 --- a/python/packages/core/tests/workflow/test_request_info_and_response.py +++ b/python/packages/core/tests/workflow/test_request_info_and_response.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from agent_framework import ( - FileCheckpointStorage, WorkflowBuilder, WorkflowContext, WorkflowEvent, @@ -323,90 +322,3 @@ async def test_invalid_calculation_input(self): 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 diff --git a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py index 73b4b938c1..dbb01d6e66 100644 --- a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py +++ b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py @@ -4,14 +4,27 @@ 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)) - deserialized = json.loads(serialized) - - assert "value" in deserialized - deserialized = deserialized["value"] + assert isinstance(serialized, str) - 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) + # 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 "req-2" in pending_request_info_events - assert pending_request_info_events["req-2"]["data"]["value"]["note"] == "slot-based" + # Verify we can rehydrate the checkpoint correctly + await runner_context.apply_checkpoint(checkpoint) + pending = await runner_context.get_pending_request_info_events() + + 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 + 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}" + ) diff --git a/python/packages/core/tests/workflow/test_runner.py b/python/packages/core/tests/workflow/test_runner.py index e527ba13fa..039c61b07d 100644 --- a/python/packages/core/tests/workflow/test_runner.py +++ b/python/packages/core/tests/workflow/test_runner.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock import pytest @@ -9,6 +10,9 @@ AgentExecutorResponse, AgentResponse, Executor, + InMemoryCheckpointStorage, + WorkflowCheckpoint, + WorkflowCheckpointException, WorkflowContext, WorkflowConvergenceException, WorkflowEvent, @@ -16,6 +20,7 @@ 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 _run(): 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 run_workflow(): 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 diff --git a/python/packages/core/tests/workflow/test_serialization.py b/python/packages/core/tests/workflow/test_serialization.py index f579c1be76..55284db407 100644 --- a/python/packages/core/tests/workflow/test_serialization.py +++ b/python/packages/core/tests/workflow/test_serialization.py @@ -647,12 +647,11 @@ def test_workflow_name_description_serialization(self) -> None: # 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() diff --git a/python/packages/core/tests/workflow/test_sub_workflow.py b/python/packages/core/tests/workflow/test_sub_workflow.py index 55afad880f..666e82f4d7 100644 --- a/python/packages/core/tests/workflow/test_sub_workflow.py +++ b/python/packages/core/tests/workflow/test_sub_workflow.py @@ -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 diff --git a/python/packages/core/tests/workflow/test_workflow.py b/python/packages/core/tests/workflow/test_workflow.py index 6728bcfcb1..744ad827ea 100644 --- a/python/packages/core/tests/workflow/test_workflow.py +++ b/python/packages/core/tests/workflow/test_workflow.py @@ -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,23 +384,25 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu 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, + previous_checkpoint_id=None, 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) - .add_edge(simple_executor, simple_executor) - .build() - ) + checkpoint_id = await storage.save(test_checkpoint) # Test non-streaming run method with checkpoint_id result = await workflow.run(checkpoint_id=checkpoint_id) @@ -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" diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 1ccc400f92..2a1532502b 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -607,7 +607,7 @@ async def test_checkpoint_storage_passed_to_workflow(self) -> None: # 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): diff --git a/python/packages/core/tests/workflow/test_workflow_observability.py b/python/packages/core/tests/workflow/test_workflow_observability.py index d5c20ad429..b2260abe63 100644 --- a/python/packages/core/tests/workflow/test_workflow_observability.py +++ b/python/packages/core/tests/workflow/test_workflow_observability.py @@ -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) diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index b55a57cf44..92e6301b66 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -430,7 +430,7 @@ async def _execute_workflow( 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 diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index 374bab9962..e7994d3d3b 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -1059,7 +1059,7 @@ async def delete_conversation_item(conversation_id: str, item_id: str) -> dict[s # 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") diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index d5ead8d9e7..d7ac1576c7 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -32,7 +32,6 @@ 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 @@ async def _check_agent_terminate_and_yield( 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 @@ async def on_checkpoint_save(self) -> dict[str, Any]: 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) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py index 0f23f96dc0..e8f8a81080 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py @@ -59,15 +59,14 @@ def to_dict(self) -> dict[str, Any]: 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 @@ def from_dict(cls, data: dict[str, Any]) -> 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, ) diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py index 55100af4c3..8712aae3fd 100644 --- a/python/packages/orchestrations/tests/test_concurrent.py +++ b/python/packages/orchestrations/tests/test_concurrent.py @@ -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" diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 9eb94b19d4..6544b681a0 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -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" diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py index b24284f9c3..17b6957205 100644 --- a/python/packages/orchestrations/tests/test_magentic.py +++ b/python/packages/orchestrations/tests/test_magentic.py @@ -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 diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index 880e33761d..04a4ae4141 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -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" diff --git a/python/samples/getting_started/orchestrations/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/getting_started/orchestrations/handoff_with_tool_approval_checkpoint_resume.py new file mode 100644 index 0000000000..ce377b654d --- /dev/null +++ b/python/samples/getting_started/orchestrations/handoff_with_tool_approval_checkpoint_resume.py @@ -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()) diff --git a/python/samples/getting_started/orchestrations/magentic_checkpoint.py b/python/samples/getting_started/orchestrations/magentic_checkpoint.py index 05437a8601..adce878f0d 100644 --- a/python/samples/getting_started/orchestrations/magentic_checkpoint.py +++ b/python/samples/getting_started/orchestrations/magentic_checkpoint.py @@ -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 @@ def _pending_message_count(cp: WorkflowCheckpoint) -> int: ] 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 @@ def _pending_message_count(cp: WorkflowCheckpoint) -> int: 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( diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py index ec194d0fa3..12cb08a8be 100644 --- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py +++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py @@ -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 @@ 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 diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py index 22a8423cba..572dd4f0ee 100644 --- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py +++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py @@ -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})" diff --git a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py deleted file mode 100644 index f39c997457..0000000000 --- a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py +++ /dev/null @@ -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()) diff --git a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py index b93a58a50c..833bd7c920 100644 --- a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py +++ b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py @@ -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" diff --git a/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py b/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py index 4fc980e008..552ced2892 100644 --- a/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py +++ b/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py @@ -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)}") diff --git a/python/uv.lock b/python/uv.lock index fac55c8e21..d9f3163dec 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -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 = [ @@ -1673,62 +1673,62 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.4" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and 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/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, - { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, - { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, - { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, - { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, - { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, - { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, - { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, - { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, - { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, - { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, - { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, - { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, - { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, - { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, - { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, - { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, - { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, - { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, - { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, - { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] @@ -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" -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" } -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" }, +version = "12.1.1" +source = { registry = "https://pypi.org/simple" } +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/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" } -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" }, +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/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]]