diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py index 99b0c1f7d6..324d43faba 100644 --- a/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_app.py @@ -6,6 +6,7 @@ with Azure Durable Entities, enabling stateful and durable AI agent execution. """ +import asyncio import json import re import uuid @@ -16,7 +17,7 @@ import azure.durable_functions as df import azure.functions as func -from agent_framework import AgentProtocol, get_logger +from agent_framework import AgentExecutor, AgentProtocol, Workflow, WorkflowOutputEvent, get_logger from agent_framework_durabletask import ( DEFAULT_MAX_POLL_RETRIES, DEFAULT_POLL_INTERVAL_SECONDS, @@ -39,6 +40,14 @@ from ._entities import create_agent_entity from ._errors import IncomingRequestError from ._orchestration import AgentOrchestrationContextType, AgentTask, AzureFunctionsAgentExecutor +from ._utils import ( + CapturingRunnerContext, + _execute_hitl_response_handler, + deserialize_value, + reconstruct_message_for_handler, + serialize_message, +) +from ._workflow import run_workflow_orchestrator logger = get_logger("agent_framework.azurefunctions") @@ -151,16 +160,19 @@ def my_orchestration(context): enable_mcp_tool_trigger: Whether MCP tool triggers are created for agents max_poll_retries: Maximum polling attempts when waiting for responses poll_interval_seconds: Delay (seconds) between polling attempts + workflow: Optional Workflow instance for workflow orchestration """ _agent_metadata: dict[str, AgentMetadata] enable_health_check: bool enable_http_endpoints: bool enable_mcp_tool_trigger: bool + workflow: Workflow | None def __init__( self, agents: list[AgentProtocol] | None = None, + workflow: Workflow | None = None, http_auth_level: func.AuthLevel = func.AuthLevel.FUNCTION, enable_health_check: bool = True, enable_http_endpoints: bool = True, @@ -172,6 +184,7 @@ def __init__( """Initialize the AgentFunctionApp. :param agents: List of agent instances to register. + :param workflow: Optional Workflow instance to extract agents from and set up orchestration. :param http_auth_level: HTTP authentication level (default: ``func.AuthLevel.FUNCTION``). :param enable_health_check: Enable the built-in health check endpoint (default: ``True``). :param enable_http_endpoints: Enable HTTP endpoints for agents (default: ``True``). @@ -196,6 +209,7 @@ def __init__( self.enable_http_endpoints = enable_http_endpoints self.enable_mcp_tool_trigger = enable_mcp_tool_trigger self.default_callback = default_callback + self.workflow = workflow try: retries = int(max_poll_retries) @@ -209,6 +223,20 @@ def __init__( interval = DEFAULT_POLL_INTERVAL_SECONDS self.poll_interval_seconds = interval if interval > 0 else DEFAULT_POLL_INTERVAL_SECONDS + # If workflow is provided, extract agents and set up orchestration + if workflow: + if agents is None: + agents = [] + logger.debug("[AgentFunctionApp] Extracting agents from workflow") + for executor in workflow.executors.values(): + if isinstance(executor, AgentExecutor): + agents.append(executor.agent) + else: + # Setup individual activity for each non-agent executor + self._setup_executor_activity(executor.id) + + self._setup_workflow_orchestration() + if agents: # Register all provided agents logger.debug(f"[AgentFunctionApp] Registering {len(agents)} agent(s)") @@ -221,6 +249,294 @@ def __init__( logger.debug("[AgentFunctionApp] Initialization complete") + def _setup_executor_activity(self, executor_id: str) -> None: + """Register an activity for executing a specific non-agent executor. + + Args: + executor_id: The ID of the executor to create an activity for. + """ + activity_name = f"dafx-{executor_id}" + logger.debug(f"[AgentFunctionApp] Registering activity '{activity_name}' for executor '{executor_id}'") + + # Capture executor_id in closure + captured_executor_id = executor_id + + @self.function_name(activity_name) + @self.activity_trigger(input_name="inputData") + def executor_activity(inputData: str) -> str: + """Activity to execute a specific non-agent executor. + + Note: We use str type annotations instead of dict to work around + Azure Functions worker type validation issues with dict[str, Any]. + """ + import json as json_module + + from agent_framework import SharedState + + data = json_module.loads(inputData) + message_data = data["message"] + shared_state_snapshot = data.get("shared_state_snapshot", {}) + source_executor_ids = data.get("source_executor_ids", ["__orchestrator__"]) + + if not self.workflow: + raise RuntimeError("Workflow not initialized in AgentFunctionApp") + + executor = self.workflow.executors.get(captured_executor_id) + if not executor: + raise ValueError(f"Unknown executor: {captured_executor_id}") + + # Reconstruct message - try to match handler's expected types using public input_types + message = reconstruct_message_for_handler(message_data, executor.input_types) + + # Check if this is a HITL response message + is_hitl_response = isinstance(message_data, dict) and message_data.get("__hitl_response__") + + async def run() -> dict[str, Any]: + # Create runner context and shared state + runner_context = CapturingRunnerContext() + shared_state = SharedState() + + # Deserialize shared state values to reconstruct dataclasses/Pydantic models + deserialized_state = {k: deserialize_value(v) for k, v in (shared_state_snapshot or {}).items()} + original_snapshot = dict(deserialized_state) + await shared_state.import_state(deserialized_state) + + if is_hitl_response: + # Handle HITL response by calling the executor's @response_handler + await _execute_hitl_response_handler( + executor=executor, + hitl_message=message_data, + shared_state=shared_state, + runner_context=runner_context, + ) + else: + # Execute using the public execute() method + await executor.execute( + message=message, + source_executor_ids=source_executor_ids, + shared_state=shared_state, + runner_context=runner_context, + ) + + # Export current state and compute changes + current_state = await shared_state.export_state() + original_keys = set(original_snapshot.keys()) + current_keys = set(current_state.keys()) + + # Deleted = was in original, not in current + deletes = original_keys - current_keys + + # Updates = keys in current that are new or have different values + updates = { + k: v for k, v in current_state.items() if k not in original_snapshot or original_snapshot[k] != v + } + + # Drain messages and events from runner context + sent_messages = await runner_context.drain_messages() + events = await runner_context.drain_events() + + # Extract outputs from WorkflowOutputEvent instances + outputs: list[Any] = [] + for event in events: + if isinstance(event, WorkflowOutputEvent): + outputs.append(serialize_message(event.data)) + + # Get pending request info events for HITL + pending_request_info_events = await runner_context.get_pending_request_info_events() + + # Serialize pending request info events for orchestrator + serialized_pending_requests = [] + for _request_id, event in pending_request_info_events.items(): + serialized_pending_requests.append({ + "request_id": event.request_id, + "source_executor_id": event.source_executor_id, + "data": serialize_message(event.data), + "request_type": f"{type(event.data).__module__}:{type(event.data).__name__}", + "response_type": f"{event.response_type.__module__}:{event.response_type.__name__}" + if event.response_type + else None, + }) + + # Serialize messages for JSON compatibility + serialized_sent_messages = [] + for _source_id, msg_list in sent_messages.items(): + for msg in msg_list: + serialized_sent_messages.append({ + "message": serialize_message(msg.data), + "target_id": msg.target_id, + "source_id": msg.source_id, + }) + + serialized_updates = {k: serialize_message(v) for k, v in updates.items()} + + return { + "sent_messages": serialized_sent_messages, + "outputs": outputs, + "shared_state_updates": serialized_updates, + "shared_state_deletes": list(deletes), + "pending_request_info_events": serialized_pending_requests, + } + + result = asyncio.run(run()) + return json_module.dumps(result) + + # Ensure the function is registered (prevents garbage collection) + _ = executor_activity + + def _setup_workflow_orchestration(self) -> None: + """Register the workflow orchestration and related HTTP endpoints.""" + + @self.orchestration_trigger(context_name="context") + def workflow_orchestrator(context: df.DurableOrchestrationContext): # type: ignore[type-arg] + """Generic orchestrator for running the configured workflow.""" + input_data = context.get_input() + + # Ensure input is a string for the agent + initial_message = json.dumps(input_data) if isinstance(input_data, (dict, list)) else str(input_data) + + # Create local shared state dict for cross-executor state sharing + shared_state: dict[str, Any] = {} + + outputs = yield from run_workflow_orchestrator(context, self.workflow, initial_message, shared_state) + # Durable Functions runtime extracts return value from StopIteration + return outputs # noqa: B901 + + @self.route(route="workflow/run", methods=["POST"]) + @self.durable_client_input(client_name="client") + async def start_workflow_orchestration( + req: func.HttpRequest, client: df.DurableOrchestrationClient + ) -> func.HttpResponse: + """HTTP endpoint to start the workflow.""" + try: + req_body = req.get_json() + except ValueError: + return func.HttpResponse( + json.dumps({"error": "Invalid JSON body"}), + status_code=400, + mimetype="application/json", + ) + + instance_id = await client.start_new("workflow_orchestrator", client_input=req_body) + + base_url = self._build_base_url(req.url) + status_url = f"{base_url}/api/workflow/status/{instance_id}" + + return func.HttpResponse( + json.dumps({ + "instanceId": instance_id, + "statusQueryGetUri": status_url, + "respondUri": f"{base_url}/api/workflow/respond/{instance_id}/{{requestId}}", + "message": "Workflow started", + }), + status_code=202, + mimetype="application/json", + ) + + @self.route(route="workflow/status/{instanceId}", methods=["GET"]) + @self.durable_client_input(client_name="client") + async def get_workflow_status( + req: func.HttpRequest, client: df.DurableOrchestrationClient + ) -> func.HttpResponse: + """HTTP endpoint to get workflow status.""" + instance_id = req.route_params.get("instanceId") + status = await client.get_status(instance_id) + + if not status: + return func.HttpResponse( + json.dumps({"error": "Instance not found"}), + status_code=404, + mimetype="application/json", + ) + + response = { + "instanceId": status.instance_id, + "runtimeStatus": status.runtime_status.name if status.runtime_status else None, + "customStatus": status.custom_status, + "output": status.output, + "error": status.output if status.runtime_status == df.OrchestrationRuntimeStatus.Failed else None, + "createdTime": status.created_time.isoformat() if status.created_time else None, + "lastUpdatedTime": status.last_updated_time.isoformat() if status.last_updated_time else None, + } + + # Add pending HITL requests info if available + custom_status = status.custom_status or {} + if isinstance(custom_status, dict) and custom_status.get("pending_requests"): + base_url = self._build_base_url(req.url) + pending_requests = [] + for req_id, req_data in custom_status["pending_requests"].items(): + pending_requests.append({ + "requestId": req_id, + "sourceExecutor": req_data.get("source_executor_id"), + "requestData": req_data.get("data"), + "requestType": req_data.get("request_type"), + "responseType": req_data.get("response_type"), + "respondUrl": f"{base_url}/api/workflow/respond/{instance_id}/{req_id}", + }) + response["pendingHumanInputRequests"] = pending_requests + + return func.HttpResponse( + json.dumps(response, default=str), + status_code=200, + mimetype="application/json", + ) + + @self.route(route="workflow/respond/{instanceId}/{requestId}", methods=["POST"]) + @self.durable_client_input(client_name="client") + async def send_hitl_response(req: func.HttpRequest, client: df.DurableOrchestrationClient) -> func.HttpResponse: + """HTTP endpoint to send a response to a pending HITL request. + + The requestId in the URL corresponds to the request_id from the RequestInfoEvent. + The request body should contain the response data matching the expected response_type. + """ + instance_id = req.route_params.get("instanceId") + request_id = req.route_params.get("requestId") + + if not instance_id or not request_id: + return func.HttpResponse( + json.dumps({"error": "Instance ID and Request ID are required."}), + status_code=400, + mimetype="application/json", + ) + + try: + response_data = req.get_json() + except ValueError: + return func.HttpResponse( + json.dumps({"error": "Request body must be valid JSON."}), + status_code=400, + mimetype="application/json", + ) + + # Send the response as an external event + # The request_id is used as the event name for correlation + await client.raise_event( + instance_id=instance_id, + event_name=request_id, + event_data=response_data, + ) + + return func.HttpResponse( + json.dumps({ + "message": "Response delivered successfully", + "instanceId": instance_id, + "requestId": request_id, + }), + status_code=200, + mimetype="application/json", + ) + + def _build_status_url(self, request_url: str, instance_id: str) -> str: + """Build the status URL for a workflow instance.""" + base_url = self._build_base_url(request_url) + return f"{base_url}/api/workflow/status/{instance_id}" + + def _build_base_url(self, request_url: str) -> str: + """Extract the base URL from a request URL.""" + base_url, _, _ = request_url.partition("/api/") + if not base_url: + base_url = request_url.rstrip("/") + return base_url + @property def agents(self) -> dict[str, AgentProtocol]: """Returns dict of agent names to agent instances. diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_utils.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_utils.py new file mode 100644 index 0000000000..3b25f5db85 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_utils.py @@ -0,0 +1,640 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Utility functions for workflow execution. + +This module provides helper functions for serialization, deserialization, and +context management used by the workflow orchestrator and executors. +""" + +from __future__ import annotations + +import asyncio +import logging +import types +from dataclasses import asdict, fields, is_dataclass +from typing import Any, Union, get_args, get_origin + +from agent_framework import ( + AgentExecutorRequest, + AgentExecutorResponse, + AgentRunResponse, + ChatMessage, + CheckpointStorage, + Message, + RequestInfoEvent, + RunnerContext, + SharedState, + WorkflowCheckpoint, + WorkflowEvent, +) +from pydantic import BaseModel + +logger = logging.getLogger(__name__) + + +class CapturingRunnerContext(RunnerContext): + """A RunnerContext implementation that captures messages and events for Azure Functions activities. + + This context is designed for executing standard Executors within Azure Functions activities. + It captures all messages and events produced during execution without requiring durable + entity storage, allowing the results to be returned to the orchestrator. + + Unlike the full InProcRunnerContext, this implementation: + - Does NOT support checkpointing (always returns False for has_checkpointing) + - Does NOT support streaming (always returns False for is_streaming) + - Captures messages and events in memory for later retrieval + + The orchestrator manages state coordination; this context just captures execution output. + """ + + def __init__(self) -> None: + """Initialize the capturing runner context.""" + self._messages: dict[str, list[Message]] = {} + self._event_queue: asyncio.Queue[WorkflowEvent] = asyncio.Queue() + self._pending_request_info_events: dict[str, RequestInfoEvent] = {} + self._workflow_id: str | None = None + self._streaming: bool = False + + # region Messaging + + async def send_message(self, message: Message) -> None: + """Capture a message sent by an executor.""" + self._messages.setdefault(message.source_id, []) + self._messages[message.source_id].append(message) + + async def drain_messages(self) -> dict[str, list[Message]]: + """Drain and return all captured messages.""" + from copy import copy + + messages = copy(self._messages) + self._messages.clear() + return messages + + async def has_messages(self) -> bool: + """Check if there are any captured messages.""" + return bool(self._messages) + + # endregion Messaging + + # region Events + + async def add_event(self, event: WorkflowEvent) -> None: + """Capture an event produced during execution.""" + await self._event_queue.put(event) + + async def drain_events(self) -> list[WorkflowEvent]: + """Drain all currently queued events without blocking.""" + events: list[WorkflowEvent] = [] + while True: + try: + events.append(self._event_queue.get_nowait()) + except asyncio.QueueEmpty: + break + return events + + async def has_events(self) -> bool: + """Check if there are any queued events.""" + return not self._event_queue.empty() + + async def next_event(self) -> WorkflowEvent: + """Wait for and return the next event.""" + return await self._event_queue.get() + + # endregion Events + + # region Checkpointing (not supported in activity context) + + def has_checkpointing(self) -> bool: + """Checkpointing is not supported in activity context.""" + return False + + def set_runtime_checkpoint_storage(self, storage: CheckpointStorage) -> None: + """No-op: checkpointing not supported in activity context.""" + pass + + def clear_runtime_checkpoint_storage(self) -> None: + """No-op: checkpointing not supported in activity context.""" + pass + + async def create_checkpoint( + self, + shared_state: SharedState, + iteration_count: int, + metadata: dict[str, Any] | None = None, + ) -> str: + """Checkpointing not supported in activity context.""" + raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context") + + async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: + """Checkpointing not supported in activity context.""" + raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context") + + async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None: + """Checkpointing not supported in activity context.""" + raise NotImplementedError("Checkpointing is not supported in Azure Functions activity context") + + # endregion Checkpointing + + # region Workflow Configuration + + def set_workflow_id(self, workflow_id: str) -> None: + """Set the workflow ID.""" + self._workflow_id = workflow_id + + def reset_for_new_run(self) -> None: + """Reset the context for a new run.""" + self._messages.clear() + self._event_queue = asyncio.Queue() + self._pending_request_info_events.clear() + self._streaming = False + + def set_streaming(self, streaming: bool) -> None: + """Set streaming mode (not used in activity context).""" + self._streaming = streaming + + def is_streaming(self) -> bool: + """Check if streaming mode is enabled (always False in activity context).""" + return self._streaming + + # endregion Workflow Configuration + + # region Request Info Events + + async def add_request_info_event(self, event: RequestInfoEvent) -> None: + """Add a RequestInfoEvent and track it for correlation.""" + self._pending_request_info_events[event.request_id] = event + await self.add_event(event) + + async def send_request_info_response(self, request_id: str, response: Any) -> None: + """Send a response correlated to a pending request. + + Note: This is not supported in activity context since human-in-the-loop + scenarios require orchestrator-level coordination. + """ + raise NotImplementedError( + "send_request_info_response is not supported in Azure Functions activity context. " + "Human-in-the-loop scenarios should be handled at the orchestrator level." + ) + + async def get_pending_request_info_events(self) -> dict[str, RequestInfoEvent]: + """Get the mapping of request IDs to their corresponding RequestInfoEvent.""" + return dict(self._pending_request_info_events) + + # endregion Request Info Events + + +def _serialize_value(value: Any) -> Any: + """Recursively serialize a value for JSON compatibility.""" + # Handle None + if value is None: + return None + + # Handle objects with to_dict() method (like ChatMessage) + if hasattr(value, "to_dict") and callable(value.to_dict): + return value.to_dict() + + # Handle dataclasses + if is_dataclass(value) and not isinstance(value, type): + d: dict[str, Any] = {} + for k, v in asdict(value).items(): + d[k] = _serialize_value(v) + d["__type__"] = type(value).__name__ + d["__module__"] = type(value).__module__ + return d + + # Handle Pydantic models + if isinstance(value, BaseModel): + d = value.model_dump() + d["__type__"] = type(value).__name__ + d["__module__"] = type(value).__module__ + return d + + # Handle lists + if isinstance(value, list): + return [_serialize_value(item) for item in value] + + # Handle dicts + if isinstance(value, dict): + return {k: _serialize_value(v) for k, v in value.items()} + + # Handle primitives and other types + return value + + +def serialize_message(message: Any) -> Any: + """Helper to serialize messages for activity input. + + Adds type metadata (__type__, __module__) to dataclasses and Pydantic models + to enable reconstruction on the receiving end. Handles nested ChatMessage + and other objects with to_dict() methods. + """ + return _serialize_value(message) + + +def deserialize_value(data: Any, type_registry: dict[str, type] | None = None) -> Any: + """Attempt to deserialize a value using embedded type metadata. + + Args: + data: The serialized data (could be dict with __type__ metadata) + type_registry: Optional dict mapping type names to types for reconstruction + + Returns: + Reconstructed object if type metadata found and type available, otherwise original data + """ + if not isinstance(data, dict): + return data + + type_name = data.get("__type__") + module_name = data.get("__module__") + + # Special handling for MAF types with nested objects + if type_name == "AgentExecutorRequest" or ("messages" in data and "should_respond" in data): + try: + return reconstruct_agent_executor_request(data) + except Exception: + logger.debug("Could not reconstruct as AgentExecutorRequest, trying next strategy") + + if type_name == "AgentExecutorResponse" or ("executor_id" in data and "agent_run_response" in data): + try: + return reconstruct_agent_executor_response(data) + except Exception: + logger.debug("Could not reconstruct as AgentExecutorResponse, trying next strategy") + + if not type_name: + return data + + # Try to find the type + target_type = None + + # First check the registry + if type_registry and type_name in type_registry: + target_type = type_registry[type_name] + else: + # Try to import from module + if module_name: + try: + import importlib + + module = importlib.import_module(module_name) + target_type = getattr(module, type_name, None) + except Exception: + logger.debug("Could not import module %s for type %s", module_name, type_name) + + if target_type: + # Remove metadata before reconstruction + clean_data = {k: v for k, v in data.items() if not k.startswith("__")} + try: + if is_dataclass(target_type): + # Recursively reconstruct nested fields for dataclasses + reconstructed_data = _reconstruct_dataclass_fields(target_type, clean_data) + return target_type(**reconstructed_data) + if issubclass(target_type, BaseModel): + # Pydantic handles nested model validation automatically + return target_type.model_validate(clean_data) + except Exception: + logger.debug("Could not reconstruct type %s from data", type_name) + + return data + + +def _reconstruct_dataclass_fields(dataclass_type: type, data: dict[str, Any]) -> dict[str, Any]: + """Recursively reconstruct nested dataclass and Pydantic fields. + + This function processes each field of a dataclass, looking up the expected type + from type hints and reconstructing nested objects (dataclasses, Pydantic models, lists). + + Args: + dataclass_type: The dataclass type being constructed + data: The dict of field values + + Returns: + Dict with nested objects properly reconstructed + """ + if not is_dataclass(dataclass_type): + return data + + result = {} + type_hints = {} + + # Get type hints for the dataclass + try: + import typing + + type_hints = typing.get_type_hints(dataclass_type) + except Exception: + # Fall back to field annotations if get_type_hints fails + for f in fields(dataclass_type): + type_hints[f.name] = f.type + + for key, value in data.items(): + if key not in type_hints: + result[key] = value + continue + + field_type = type_hints[key] + + # Handle Optional types (Union with None) + origin = get_origin(field_type) + if origin is Union or isinstance(field_type, types.UnionType): + args = get_args(field_type) + # Filter out NoneType to get the actual type + non_none_types = [t for t in args if t is not type(None)] + if len(non_none_types) == 1: + field_type = non_none_types[0] + + # Recursively reconstruct the value + result[key] = _reconstruct_typed_value(value, field_type) + + return result + + +def _reconstruct_typed_value(value: Any, target_type: type) -> Any: + """Reconstruct a single value to the target type. + + Handles dataclasses, Pydantic models, and lists with typed elements. + + Args: + value: The value to reconstruct + target_type: The expected type + + Returns: + The reconstructed value + """ + if value is None: + return None + + # If already the correct type, return as-is + try: + if isinstance(value, target_type): + return value + except TypeError: + # target_type might not be a valid type for isinstance + pass + + # Handle dict values that need reconstruction + if isinstance(value, dict): + # First try deserialize_value which uses embedded type metadata + if "__type__" in value: + deserialized = deserialize_value(value) + if deserialized is not value: + return deserialized + + # Handle Pydantic models + if hasattr(target_type, "model_validate"): + try: + return target_type.model_validate(value) + except Exception: + logger.debug("Could not validate Pydantic model %s", target_type) + + # Handle dataclasses + if is_dataclass(target_type) and isinstance(target_type, type): + try: + # Recursively reconstruct nested fields + reconstructed = _reconstruct_dataclass_fields(target_type, value) + return target_type(**reconstructed) + except Exception: + logger.debug("Could not construct dataclass %s", target_type) + + # Handle list values + if isinstance(value, list): + origin = get_origin(target_type) + if origin is list: + args = get_args(target_type) + if args: + element_type = args[0] + return [_reconstruct_typed_value(item, element_type) for item in value] + + return value + + +def reconstruct_agent_executor_request(data: dict[str, Any]) -> AgentExecutorRequest: + """Helper to reconstruct AgentExecutorRequest from dict.""" + # Reconstruct ChatMessage objects in messages + messages_data = data.get("messages", []) + messages = [ChatMessage.from_dict(m) if isinstance(m, dict) else m for m in messages_data] + + return AgentExecutorRequest(messages=messages, should_respond=data.get("should_respond", True)) + + +def reconstruct_agent_executor_response(data: dict[str, Any]) -> AgentExecutorResponse: + """Helper to reconstruct AgentExecutorResponse from dict.""" + # Reconstruct AgentRunResponse + arr_data = data.get("agent_run_response", {}) + agent_run_response = AgentRunResponse.from_dict(arr_data) if isinstance(arr_data, dict) else arr_data + + # Reconstruct full_conversation + fc_data = data.get("full_conversation", []) + full_conversation = None + if fc_data: + full_conversation = [ChatMessage.from_dict(m) if isinstance(m, dict) else m for m in fc_data] + + return AgentExecutorResponse( + executor_id=data["executor_id"], agent_run_response=agent_run_response, full_conversation=full_conversation + ) + + +def reconstruct_message_for_handler(data: Any, input_types: list[type[Any]]) -> Any: + """Attempt to reconstruct a message to match one of the handler's expected types. + + Handles: + - Dicts with __type__ metadata -> reconstructs to original dataclass/Pydantic model + - Lists (from fan-in) -> recursively reconstructs each item + - Union types (T | U) -> tries each type in the union + - AgentExecutorRequest/Response -> special handling for nested ChatMessage objects + + Args: + data: The serialized message data (could be dict, str, list, etc.) + input_types: List of message types the executor can accept + + Returns: + Reconstructed message if possible, otherwise the original data + """ + # Flatten union types in input_types (e.g., T | U becomes [T, U]) + flattened_types: list[type[Any]] = [] + for input_type in input_types: + origin = get_origin(input_type) + # Handle both typing.Union and types.UnionType (Python 3.10+ | syntax) + if origin is Union or isinstance(input_type, types.UnionType): + # This is a Union type (T | U), extract the component types + flattened_types.extend(get_args(input_type)) + else: + flattened_types.append(input_type) + + # Handle lists (fan-in aggregation) - recursively reconstruct each item + if isinstance(data, list): + # Extract element types from list[T] annotations in input_types if possible + element_types: list[type[Any]] = [] + for input_type in input_types: + origin = get_origin(input_type) + if origin is list: + args = get_args(input_type) + if args: + # Handle union types inside list[T | U] + for arg in args: + arg_origin = get_origin(arg) + if arg_origin is Union or isinstance(arg, types.UnionType): + element_types.extend(get_args(arg)) + else: + element_types.append(arg) + + # Recursively reconstruct each item in the list + return [reconstruct_message_for_handler(item, element_types or flattened_types) for item in data] + + if not isinstance(data, dict): + return data + + # Try AgentExecutorResponse first - it needs special handling for nested objects + if "executor_id" in data and "agent_run_response" in data: + try: + return reconstruct_agent_executor_response(data) + except Exception: + logger.debug("Could not reconstruct as AgentExecutorResponse in handler context") + + # Try AgentExecutorRequest - also needs special handling for nested ChatMessage objects + if "messages" in data and "should_respond" in data: + try: + return reconstruct_agent_executor_request(data) + except Exception: + logger.debug("Could not reconstruct as AgentExecutorRequest in handler context") + + # Try deserialize_value which uses embedded type metadata (__type__, __module__) + if "__type__" in data: + deserialized = deserialize_value(data) + if deserialized is not data: + return deserialized + + # Try to match against input types by checking dict keys vs dataclass fields + # Filter out metadata keys when comparing + data_keys = {k for k in data if not k.startswith("__")} + for msg_type in flattened_types: + if is_dataclass(msg_type): + # Check if the dict keys match the dataclass fields + field_names = {f.name for f in fields(msg_type)} + if field_names == data_keys or field_names.issubset(data_keys): + try: + # Remove metadata before constructing + clean_data = {k: v for k, v in data.items() if not k.startswith("__")} + # Recursively reconstruct nested objects based on field types + reconstructed_data = _reconstruct_dataclass_fields(msg_type, clean_data) + return msg_type(**reconstructed_data) + except Exception: + logger.debug("Could not construct %s from matching fields", msg_type.__name__) + + return data + + +# ============================================================================ +# HITL Response Handler Execution +# ============================================================================ + + +async def _execute_hitl_response_handler( + executor: Any, + hitl_message: dict[str, Any], + shared_state: SharedState, + runner_context: CapturingRunnerContext, +) -> None: + """Execute a HITL response handler on an executor. + + This function handles the delivery of a HITL response to the executor's + @response_handler method. It: + 1. Deserializes the original request and response + 2. Finds the matching response handler based on types + 3. Creates a WorkflowContext and invokes the handler + + Args: + executor: The executor instance that has a @response_handler + hitl_message: The HITL response message containing original_request and response + shared_state: The shared state for the workflow context + runner_context: The runner context for capturing outputs + """ + from agent_framework._workflows._workflow_context import WorkflowContext + + # Extract the response data + original_request_data = hitl_message.get("original_request") + response_data = hitl_message.get("response") + response_type_str = hitl_message.get("response_type") + + # Deserialize the original request + original_request = deserialize_value(original_request_data) + + # Deserialize the response - try to match expected type + response = _deserialize_hitl_response(response_data, response_type_str) + + # Find the matching response handler + handler = executor._find_response_handler(original_request, response) + + if handler is None: + logger.warning( + "No response handler found for HITL response in executor %s. Request type: %s, Response type: %s", + executor.id, + type(original_request).__name__, + type(response).__name__, + ) + return + + # Create a WorkflowContext for the handler + # Use a special source ID to indicate this is a HITL response + ctx = WorkflowContext( + executor=executor, + source_executor_ids=["__hitl_response__"], + runner_context=runner_context, + shared_state=shared_state, + ) + + # Call the response handler + # Note: handler is already a partial with original_request bound + logger.debug( + "Invoking response handler for HITL request in executor %s", + executor.id, + ) + await handler(response, ctx) + + +def _deserialize_hitl_response(response_data: Any, response_type_str: str | None) -> Any: + """Deserialize a HITL response to its expected type. + + Args: + response_data: The raw response data (typically a dict from JSON) + response_type_str: The fully qualified type name (module:classname) + + Returns: + The deserialized response, or the original data if deserialization fails + """ + logger.debug( + "Deserializing HITL response. response_type_str=%s, response_data type=%s", + response_type_str, + type(response_data).__name__, + ) + + if response_data is None: + return None + + # If already a primitive, return as-is + if not isinstance(response_data, dict): + logger.debug("Response data is not a dict, returning as-is: %s", type(response_data).__name__) + return response_data + + # Try to deserialize using the type hint + if response_type_str: + try: + module_name, class_name = response_type_str.rsplit(":", 1) + import importlib + + module = importlib.import_module(module_name) + response_type = getattr(module, class_name, None) + + if response_type: + logger.debug("Found response type %s, attempting reconstruction", response_type) + # Use the shared reconstruction logic which handles nested objects + result = _reconstruct_typed_value(response_data, response_type) + logger.debug("Reconstructed response type: %s", type(result).__name__) + return result + logger.warning("Could not find class %s in module %s", class_name, module_name) + + except Exception as e: + logger.warning("Could not deserialize HITL response to %s: %s", response_type_str, e) + + # Fall back to generic deserialization + logger.debug("Falling back to generic deserialization") + return deserialize_value(response_data) diff --git a/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py b/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py new file mode 100644 index 0000000000..20bb12db63 --- /dev/null +++ b/python/packages/azurefunctions/agent_framework_azurefunctions/_workflow.py @@ -0,0 +1,854 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Workflow Execution for Durable Functions. + +This module provides the workflow orchestration engine that executes MAF Workflows +using Azure Durable Functions. It reuses MAF's edge group routing logic while +adapting execution to the DF generator-based model (yield instead of await). + +Key components: +- run_workflow_orchestrator: Main orchestration function for workflow execution +- route_message_through_edge_groups: Routing helper using MAF edge group APIs +- build_agent_executor_response: Helper to construct AgentExecutorResponse + +HITL (Human-in-the-Loop) Support: +- Detects pending RequestInfoEvents from executor activities +- Uses wait_for_external_event to pause for human input +- Routes responses back to executor's @response_handler methods +""" + +from __future__ import annotations + +import json +import logging +from collections import defaultdict +from dataclasses import dataclass +from datetime import timedelta +from enum import Enum +from typing import Any + +from agent_framework import ( + AgentExecutor, + AgentExecutorRequest, + AgentExecutorResponse, + AgentRunResponse, + ChatMessage, + Workflow, +) +from agent_framework._workflows._edge import ( + EdgeGroup, + FanInEdgeGroup, + FanOutEdgeGroup, + SingleEdgeGroup, + SwitchCaseEdgeGroup, +) +from agent_framework_durabletask import AgentSessionId, DurableAgentThread, DurableAIAgent +from azure.durable_functions import DurableOrchestrationContext + +from ._orchestration import AzureFunctionsAgentExecutor +from ._utils import deserialize_value, serialize_message + +logger = logging.getLogger(__name__) + + +# ============================================================================ +# Task Types and Data Structures +# ============================================================================ + + +class TaskType(Enum): + """Type of executor task.""" + + AGENT = "agent" + ACTIVITY = "activity" + + +@dataclass +class TaskMetadata: + """Metadata for a pending task.""" + + executor_id: str + message: Any + source_executor_id: str + task_type: TaskType + remaining_messages: list[tuple[str, Any, str]] | None = None # For agents with multiple messages + + +@dataclass +class ExecutorResult: + """Result from executing an agent or activity.""" + + executor_id: str + output_message: AgentExecutorResponse | None + activity_result: dict[str, Any] | None + task_type: TaskType + + +@dataclass +class PendingHITLRequest: + """Tracks a pending Human-in-the-Loop request in the orchestrator. + + Attributes: + request_id: Unique identifier for correlation with external events + source_executor_id: The executor that called ctx.request_info() + request_data: The serialized request payload + request_type: Fully qualified type name of the request data + response_type: Fully qualified type name of expected response + """ + + request_id: str + source_executor_id: str + request_data: Any + request_type: str | None + response_type: str | None + + +# Default timeout for HITL requests (72 hours) +DEFAULT_HITL_TIMEOUT_HOURS = 72.0 + + +# ============================================================================ +# Routing Functions +# ============================================================================ + + +def route_message_through_edge_groups( + edge_groups: list[EdgeGroup], + source_id: str, + message: Any, +) -> list[str]: + """Route a message through edge groups to find target executor IDs. + + Delegates to MAF's edge group routing logic instead of manual inspection. + + Args: + edge_groups: List of EdgeGroup instances from the workflow + source_id: The ID of the source executor + message: The message to route + + Returns: + List of target executor IDs that should receive the message + """ + targets: list[str] = [] + + for group in edge_groups: + if source_id not in group.source_executor_ids: + continue + + # SwitchCaseEdgeGroup and FanOutEdgeGroup use selection_func + if isinstance(group, (SwitchCaseEdgeGroup, FanOutEdgeGroup)): + if group.selection_func is not None: + selected = group.selection_func(message, group.target_executor_ids) + targets.extend(selected) + else: + # No selection func means broadcast to all targets + targets.extend(group.target_executor_ids) + + elif isinstance(group, SingleEdgeGroup): + # SingleEdgeGroup has exactly one edge + edge = group.edges[0] + if edge.should_route(message): + targets.append(edge.target_id) + + elif isinstance(group, FanInEdgeGroup): + # FanIn is handled separately in the orchestrator loop + # since it requires aggregation + pass + + else: + # Generic EdgeGroup: check each edge's condition + for edge in group.edges: + if edge.source_id == source_id and edge.should_route(message): + targets.append(edge.target_id) + + return targets + + +def build_agent_executor_response( + executor_id: str, + response_text: str | None, + structured_response: dict[str, Any] | None, + previous_message: Any, +) -> AgentExecutorResponse: + """Build an AgentExecutorResponse from entity response data. + + Shared helper to construct the response object consistently. + + Args: + executor_id: The ID of the executor that produced the response + response_text: Plain text response from the agent (if any) + structured_response: Structured JSON response (if any) + previous_message: The input message that triggered this response + + Returns: + AgentExecutorResponse with reconstructed conversation + """ + final_text = response_text + if structured_response: + final_text = json.dumps(structured_response) + + assistant_message = ChatMessage(role="assistant", text=final_text) + + agent_run_response = AgentRunResponse( + messages=[assistant_message], + ) + + # Build conversation history + full_conversation: list[ChatMessage] = [] + if isinstance(previous_message, AgentExecutorResponse) and previous_message.full_conversation: + full_conversation.extend(previous_message.full_conversation) + elif isinstance(previous_message, str): + full_conversation.append(ChatMessage(role="user", text=previous_message)) + + full_conversation.append(assistant_message) + + return AgentExecutorResponse( + executor_id=executor_id, + agent_run_response=agent_run_response, + full_conversation=full_conversation, + ) + + +# ============================================================================ +# Task Preparation Helpers +# ============================================================================ + + +def _prepare_agent_task( + context: DurableOrchestrationContext, + executor_id: str, + message: Any, +) -> Any: + """Prepare an agent task for execution. + + Args: + context: The Durable Functions orchestration context + executor_id: The agent executor ID (agent name) + message: The input message for the agent + + Returns: + A task that can be yielded to execute the agent + """ + message_content = _extract_message_content(message) + session_id = AgentSessionId(name=executor_id, key=context.instance_id) + thread = DurableAgentThread(session_id=session_id) + + az_executor = AzureFunctionsAgentExecutor(context) + agent = DurableAIAgent(az_executor, executor_id) + return agent.run(message_content, thread=thread) + + +def _prepare_activity_task( + context: DurableOrchestrationContext, + executor_id: str, + message: Any, + source_executor_id: str, + shared_state_snapshot: dict[str, Any] | None, +) -> Any: + """Prepare an activity task for execution. + + Args: + context: The Durable Functions orchestration context + executor_id: The activity executor ID + message: The input message for the activity + source_executor_id: The ID of the executor that sent the message + shared_state_snapshot: Current shared state snapshot + + Returns: + A task that can be yielded to execute the activity + """ + activity_input = { + "executor_id": executor_id, + "message": serialize_message(message), + "shared_state_snapshot": shared_state_snapshot, + "source_executor_ids": [source_executor_id], + } + activity_input_json = json.dumps(activity_input) + # Use the prefixed activity name that matches the registered function + activity_name = f"dafx-{executor_id}" + return context.call_activity(activity_name, activity_input_json) + + +# ============================================================================ +# Result Processing Helpers +# ============================================================================ + + +def _process_agent_response( + agent_response: AgentRunResponse, + executor_id: str, + message: Any, +) -> ExecutorResult: + """Process an agent response into an ExecutorResult. + + Args: + agent_response: The response from the agent + executor_id: The agent executor ID + message: The original input message + + Returns: + ExecutorResult containing the processed response + """ + response_text = agent_response.text if agent_response else None + structured_response = None + + if agent_response and agent_response.value is not None: + if hasattr(agent_response.value, "model_dump"): + structured_response = agent_response.value.model_dump() + elif isinstance(agent_response.value, dict): + structured_response = agent_response.value + + output_message = build_agent_executor_response( + executor_id=executor_id, + response_text=response_text, + structured_response=structured_response, + previous_message=message, + ) + + return ExecutorResult( + executor_id=executor_id, + output_message=output_message, + activity_result=None, + task_type=TaskType.AGENT, + ) + + +def _process_activity_result( + result_json: str | None, + executor_id: str, + shared_state: dict[str, Any] | None, + workflow_outputs: list[Any], +) -> ExecutorResult: + """Process an activity result and apply shared state updates. + + Args: + result_json: The JSON result from the activity + executor_id: The activity executor ID + shared_state: The shared state dict to update (mutated in place) + workflow_outputs: List to append outputs to (mutated in place) + + Returns: + ExecutorResult containing the processed result + """ + result = json.loads(result_json) if result_json else None + + # Apply shared state updates + if shared_state is not None and result: + if result.get("shared_state_updates"): + updates = result["shared_state_updates"] + logger.debug("[workflow] Applying SharedState updates from %s: %s", executor_id, updates) + shared_state.update(updates) + if result.get("shared_state_deletes"): + deletes = result["shared_state_deletes"] + logger.debug("[workflow] Applying SharedState deletes from %s: %s", executor_id, deletes) + for key in deletes: + shared_state.pop(key, None) + + # Collect outputs + if result and result.get("outputs"): + workflow_outputs.extend(result["outputs"]) + + return ExecutorResult( + executor_id=executor_id, + output_message=None, + activity_result=result, + task_type=TaskType.ACTIVITY, + ) + + +# ============================================================================ +# Routing Helpers +# ============================================================================ + + +def _route_result_messages( + result: ExecutorResult, + workflow: Workflow, + next_pending_messages: dict[str, list[tuple[Any, str]]], + fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]], +) -> None: + """Route messages from an executor result to their targets. + + Args: + result: The executor result containing messages to route + workflow: The workflow definition + next_pending_messages: Dict to accumulate next iteration's messages (mutated) + fan_in_pending: Dict tracking fan-in state (mutated) + """ + executor_id = result.executor_id + messages_to_route: list[tuple[Any, str | None]] = [] + + # Collect messages from agent response + if result.output_message: + messages_to_route.append((result.output_message, None)) + + # Collect sent_messages from activity results + if result.activity_result and result.activity_result.get("sent_messages"): + for msg_data in result.activity_result["sent_messages"]: + sent_msg = msg_data.get("message") + target_id = msg_data.get("target_id") + if sent_msg: + sent_msg = deserialize_value(sent_msg) + messages_to_route.append((sent_msg, target_id)) + + # Route each message + for msg_to_route, explicit_target in messages_to_route: + logger.debug("Routing output from %s", executor_id) + + # If explicit target specified, route directly + if explicit_target: + if explicit_target not in next_pending_messages: + next_pending_messages[explicit_target] = [] + next_pending_messages[explicit_target].append((msg_to_route, executor_id)) + logger.debug("Routed message from %s to explicit target %s", executor_id, explicit_target) + continue + + # Check for FanInEdgeGroup sources + for group in workflow.edge_groups: + if isinstance(group, FanInEdgeGroup) and executor_id in group.source_executor_ids: + fan_in_pending[group.id][executor_id].append((msg_to_route, executor_id)) + logger.debug("Accumulated message for FanIn group %s from %s", group.id, executor_id) + + # Use MAF's edge group routing for other edge types + targets = route_message_through_edge_groups(workflow.edge_groups, executor_id, msg_to_route) + + for target_id in targets: + logger.debug("Routing to %s", target_id) + if target_id not in next_pending_messages: + next_pending_messages[target_id] = [] + next_pending_messages[target_id].append((msg_to_route, executor_id)) + + +def _check_fan_in_ready( + workflow: Workflow, + fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]], + next_pending_messages: dict[str, list[tuple[Any, str]]], +) -> None: + """Check if any FanInEdgeGroups are ready and deliver their messages. + + Args: + workflow: The workflow definition + fan_in_pending: Dict tracking fan-in state (mutated - cleared when delivered) + next_pending_messages: Dict to add aggregated messages to (mutated) + """ + for group in workflow.edge_groups: + if not isinstance(group, FanInEdgeGroup): + continue + + pending_sources = fan_in_pending.get(group.id, {}) + + # Check if all sources have contributed at least one message + if not all(src in pending_sources and pending_sources[src] for src in group.source_executor_ids): + continue + + # Aggregate all messages into a single list + aggregated: list[Any] = [] + aggregated_sources: list[str] = [] + for src in group.source_executor_ids: + for msg, msg_source in pending_sources[src]: + aggregated.append(msg) + aggregated_sources.append(msg_source) + + target_id = group.target_executor_ids[0] + logger.debug("FanIn group %s ready, delivering %d messages to %s", group.id, len(aggregated), target_id) + + if target_id not in next_pending_messages: + next_pending_messages[target_id] = [] + + first_source = aggregated_sources[0] if aggregated_sources else "__fan_in__" + next_pending_messages[target_id].append((aggregated, first_source)) + + # Clear the pending sources for this group + fan_in_pending[group.id] = defaultdict(list) + + +# ============================================================================ +# HITL (Human-in-the-Loop) Helpers +# ============================================================================ + + +def _collect_hitl_requests( + result: ExecutorResult, + pending_hitl_requests: dict[str, PendingHITLRequest], +) -> None: + """Collect pending HITL requests from an activity result. + + Args: + result: The executor result that may contain pending request info events + pending_hitl_requests: Dict to accumulate pending requests (mutated) + """ + if result.activity_result and result.activity_result.get("pending_request_info_events"): + for req_data in result.activity_result["pending_request_info_events"]: + request_id = req_data.get("request_id") + if request_id: + pending_hitl_requests[request_id] = PendingHITLRequest( + request_id=request_id, + source_executor_id=req_data.get("source_executor_id", result.executor_id), + request_data=req_data.get("data"), + request_type=req_data.get("request_type"), + response_type=req_data.get("response_type"), + ) + logger.debug( + "Collected HITL request %s from executor %s", + request_id, + result.executor_id, + ) + + +def _route_hitl_response( + hitl_request: PendingHITLRequest, + raw_response: Any, + pending_messages: dict[str, list[tuple[Any, str]]], +) -> None: + """Route a HITL response back to the source executor's @response_handler. + + The response is packaged as a special HITL response message that the executor + activity can recognize and route to the appropriate @response_handler method. + + Args: + hitl_request: The original HITL request + raw_response: The raw response data from the external event + pending_messages: Dict to add the response message to (mutated) + """ + # Create a message structure that the executor can recognize + # This mimics what the InProcRunnerContext does for request_info responses + response_message = { + "__hitl_response__": True, + "request_id": hitl_request.request_id, + "original_request": hitl_request.request_data, + "response": raw_response, + "response_type": hitl_request.response_type, + } + + target_id = hitl_request.source_executor_id + if target_id not in pending_messages: + pending_messages[target_id] = [] + + # Use a special source ID to indicate this is a HITL response + source_id = f"__hitl_response__{hitl_request.request_id}" + pending_messages[target_id].append((response_message, source_id)) + + logger.debug( + "Routed HITL response for request %s to executor %s", + hitl_request.request_id, + target_id, + ) + + +# ============================================================================ +# Main Orchestrator +# ============================================================================ + + +def run_workflow_orchestrator( + context: DurableOrchestrationContext, + workflow: Workflow, + initial_message: Any, + shared_state: dict[str, Any] | None = None, + hitl_timeout_hours: float = DEFAULT_HITL_TIMEOUT_HOURS, +): + """Traverse and execute the workflow graph using Durable Functions. + + This orchestrator reuses MAF's edge group routing logic while adapting + execution to the DF generator-based model (yield instead of await). + + Supports: + - SingleEdgeGroup: Direct 1:1 routing with optional condition + - SwitchCaseEdgeGroup: First matching condition wins + - FanOutEdgeGroup: Broadcast to multiple targets - **executed in parallel** + - FanInEdgeGroup: Aggregates messages from multiple sources before delivery + - SharedState: Local shared state accessible to all executors + - HITL: Human-in-the-loop via request_info / @response_handler pattern + + Execution model: + - All pending executors (agents AND activities) run in parallel via single task_all() + - Multiple messages to the SAME agent are processed sequentially for conversation coherence + - SharedState updates are applied in order after parallel tasks complete + - HITL requests pause the orchestration until external events are received + + Args: + context: The Durable Functions orchestration context + workflow: The MAF Workflow instance to execute + initial_message: The initial message to send to the start executor + shared_state: Optional dict for cross-executor state sharing (local to orchestration) + hitl_timeout_hours: Timeout in hours for HITL requests (default: 72 hours) + + Returns: + List of workflow outputs collected from executor activities + """ + pending_messages: dict[str, list[tuple[Any, str]]] = { + workflow.start_executor_id: [(initial_message, "__workflow_start__")] + } + workflow_outputs: list[Any] = [] + iteration = 0 + + # Track pending sources for FanInEdgeGroups using defaultdict for cleaner access + fan_in_pending: dict[str, dict[str, list[tuple[Any, str]]]] = { + group.id: defaultdict(list) for group in workflow.edge_groups if isinstance(group, FanInEdgeGroup) + } + + # Track pending HITL requests + pending_hitl_requests: dict[str, PendingHITLRequest] = {} + + while pending_messages and iteration < workflow.max_iterations: + logger.debug("Orchestrator iteration %d", iteration) + next_pending_messages: dict[str, list[tuple[Any, str]]] = {} + + # Phase 1: Prepare all tasks (agents and activities unified) + all_tasks, task_metadata_list, remaining_agent_messages = _prepare_all_tasks( + context, workflow, pending_messages, shared_state + ) + + # Phase 2: Execute all tasks in parallel (single task_all for true parallelism) + all_results: list[ExecutorResult] = [] + if all_tasks: + logger.debug("Executing %d tasks in parallel (agents + activities)", len(all_tasks)) + raw_results = yield context.task_all(all_tasks) + logger.debug("All %d tasks completed", len(all_tasks)) + + # Process results based on task type + for idx, raw_result in enumerate(raw_results): + metadata = task_metadata_list[idx] + if metadata.task_type == TaskType.AGENT: + result = _process_agent_response(raw_result, metadata.executor_id, metadata.message) + else: + result = _process_activity_result(raw_result, metadata.executor_id, shared_state, workflow_outputs) + all_results.append(result) + + # Phase 3: Process sequential agent messages (for same-agent conversation coherence) + for executor_id, message, _source_executor_id in remaining_agent_messages: + logger.debug("Processing sequential message for agent: %s", executor_id) + task = _prepare_agent_task(context, executor_id, message) + agent_response: AgentRunResponse = yield task + logger.debug("Agent %s sequential response completed", executor_id) + + result = _process_agent_response(agent_response, executor_id, message) + all_results.append(result) + + # Phase 4: Collect pending HITL requests from activity results + for result in all_results: + _collect_hitl_requests(result, pending_hitl_requests) + + # Phase 5: Route all results to next iteration + for result in all_results: + _route_result_messages(result, workflow, next_pending_messages, fan_in_pending) + + # Phase 6: Check if any FanInEdgeGroups are ready to deliver + _check_fan_in_ready(workflow, fan_in_pending, next_pending_messages) + + pending_messages = next_pending_messages + + # Phase 7: Handle HITL - if no pending work but HITL requests exist, wait for responses + if not pending_messages and pending_hitl_requests: + logger.debug("Workflow paused for HITL - %d pending requests", len(pending_hitl_requests)) + + # Update custom status to expose pending requests + context.set_custom_status({ + "state": "waiting_for_human_input", + "pending_requests": { + req_id: { + "request_id": req.request_id, + "source_executor_id": req.source_executor_id, + "data": req.request_data, + "request_type": req.request_type, + "response_type": req.response_type, + } + for req_id, req in pending_hitl_requests.items() + }, + }) + + # Wait for external events for each pending request + # Process responses one at a time to maintain ordering + for request_id, hitl_request in list(pending_hitl_requests.items()): + logger.debug("Waiting for HITL response for request: %s", request_id) + + # Create tasks for approval and timeout + approval_task = context.wait_for_external_event(request_id) + timeout_task = context.create_timer(context.current_utc_datetime + timedelta(hours=hitl_timeout_hours)) + + winner = yield context.task_any([approval_task, timeout_task]) + + if winner == approval_task: + # Cancel the timeout + timeout_task.cancel() + + # Get the response + raw_response = approval_task.result + logger.debug( + "Received HITL response for request %s. Type: %s, Value: %s", + request_id, + type(raw_response).__name__, + raw_response, + ) + + # Durable Functions may return a JSON string; parse it if so + if isinstance(raw_response, str): + try: + import json + + raw_response = json.loads(raw_response) + logger.debug("Parsed JSON string response to: %s", type(raw_response).__name__) + except (json.JSONDecodeError, TypeError): + logger.debug("Response is not JSON, keeping as string") + + # Remove from pending + del pending_hitl_requests[request_id] + + # Route the response back to the source executor's @response_handler + _route_hitl_response( + hitl_request, + raw_response, + pending_messages, + ) + else: + # Timeout occurred + logger.warning("HITL request %s timed out after %s hours", request_id, hitl_timeout_hours) + raise TimeoutError( + f"Human-in-the-loop request '{request_id}' timed out after {hitl_timeout_hours} hours." + ) + + # Clear custom status after HITL is resolved + context.set_custom_status({"state": "running"}) + + iteration += 1 + + # Durable Functions runtime extracts return value from StopIteration + return workflow_outputs # noqa: B901 + + +def _prepare_all_tasks( + context: DurableOrchestrationContext, + workflow: Workflow, + pending_messages: dict[str, list[tuple[Any, str]]], + shared_state: dict[str, Any] | None, +) -> tuple[list[Any], list[TaskMetadata], list[tuple[str, Any, str]]]: + """Prepare all pending tasks for parallel execution. + + Groups agent messages by executor ID so that only the first message per agent + runs in the parallel batch. Additional messages to the same agent are returned + for sequential processing. + + Args: + context: The Durable Functions orchestration context + workflow: The workflow definition + pending_messages: Messages pending for each executor + shared_state: Current shared state snapshot + + Returns: + Tuple of (tasks, metadata, remaining_agent_messages): + - tasks: List of tasks ready for task_all() + - metadata: TaskMetadata for each task (same order as tasks) + - remaining_agent_messages: Agent messages requiring sequential processing + """ + all_tasks: list[Any] = [] + task_metadata_list: list[TaskMetadata] = [] + remaining_agent_messages: list[tuple[str, Any, str]] = [] + + # Group agent messages by executor_id for sequential handling of same-agent messages + agent_messages_by_executor: dict[str, list[tuple[str, Any, str]]] = defaultdict(list) + + # Categorize all pending messages + for executor_id, messages_with_sources in pending_messages.items(): + executor = workflow.executors[executor_id] + is_agent = isinstance(executor, AgentExecutor) + + for message, source_executor_id in messages_with_sources: + if is_agent: + agent_messages_by_executor[executor_id].append((executor_id, message, source_executor_id)) + else: + # Activity tasks can all run in parallel + logger.debug("Preparing activity task: %s", executor_id) + task = _prepare_activity_task(context, executor_id, message, source_executor_id, shared_state) + all_tasks.append(task) + task_metadata_list.append( + TaskMetadata( + executor_id=executor_id, + message=message, + source_executor_id=source_executor_id, + task_type=TaskType.ACTIVITY, + ) + ) + + # Process agent messages: first message per agent goes to parallel batch + for executor_id, messages_list in agent_messages_by_executor.items(): + first_msg = messages_list[0] + remaining = messages_list[1:] + + logger.debug("Preparing agent task: %s", executor_id) + task = _prepare_agent_task(context, first_msg[0], first_msg[1]) + all_tasks.append(task) + task_metadata_list.append( + TaskMetadata( + executor_id=first_msg[0], + message=first_msg[1], + source_executor_id=first_msg[2], + task_type=TaskType.AGENT, + ) + ) + + # Queue remaining messages for sequential processing + remaining_agent_messages.extend(remaining) + + return all_tasks, task_metadata_list, remaining_agent_messages + + +# ============================================================================ +# Message Content Extraction +# ============================================================================ + + +def _extract_message_content(message: Any) -> str: + """Extract text content from various message types.""" + message_content = "" + if isinstance(message, AgentExecutorResponse) and message.agent_run_response: + if message.agent_run_response.text: + message_content = message.agent_run_response.text + elif message.agent_run_response.messages: + message_content = message.agent_run_response.messages[-1].text or "" + elif isinstance(message, AgentExecutorRequest) and message.messages: + # Extract text from the last message in the request + message_content = message.messages[-1].text or "" + elif isinstance(message, dict): + message_content = _extract_message_content_from_dict(message) + elif isinstance(message, str): + message_content = message + + return message_content + + +def _extract_message_content_from_dict(message: dict[str, Any]) -> str: + """Extract text content from serialized message dictionaries.""" + message_content = "" + + if message.get("messages"): + # AgentExecutorRequest dict - messages is a list of ChatMessage dicts + last_msg = message["messages"][-1] + if isinstance(last_msg, dict): + # ChatMessage serialized via to_dict() has structure: + # {"type": "chat_message", "contents": [{"type": "text", "text": "..."}], ...} + if last_msg.get("contents"): + first_content = last_msg["contents"][0] + if isinstance(first_content, dict): + message_content = first_content.get("text") or "" + # Fallback to direct text field if not in contents structure + if not message_content: + message_content = last_msg.get("text") or last_msg.get("_text") or "" + elif hasattr(last_msg, "text"): + message_content = last_msg.text or "" + elif "agent_run_response" in message: + # AgentExecutorResponse dict + arr = message.get("agent_run_response", {}) + if isinstance(arr, dict): + message_content = arr.get("text") or "" + if not message_content and arr.get("messages"): + last_msg = arr["messages"][-1] + if isinstance(last_msg, dict): + # Check for contents structure first + if last_msg.get("contents"): + first_content = last_msg["contents"][0] + if isinstance(first_content, dict): + message_content = first_content.get("text") or "" + if not message_content: + message_content = last_msg.get("text") or last_msg.get("_text") or "" + + return message_content diff --git a/python/packages/azurefunctions/tests/test_app.py b/python/packages/azurefunctions/tests/test_app.py index 466cb8ea85..827e3374db 100644 --- a/python/packages/azurefunctions/tests/test_app.py +++ b/python/packages/azurefunctions/tests/test_app.py @@ -1108,5 +1108,102 @@ def decorator(func: TFunc) -> TFunc: assert body["agents"][0]["mcp_tool_enabled"] is True +class TestAgentFunctionAppWorkflow: + """Test suite for AgentFunctionApp workflow support.""" + + def test_init_with_workflow_stores_workflow(self) -> None: + """Test that workflow is stored when provided.""" + mock_workflow = Mock() + mock_workflow.executors = {} + + with ( + patch.object(AgentFunctionApp, "_setup_executor_activity"), + patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), + ): + app = AgentFunctionApp(workflow=mock_workflow) + + assert app.workflow is mock_workflow + + def test_init_with_workflow_extracts_agents(self) -> None: + """Test that agents are extracted from workflow executors.""" + from agent_framework import AgentExecutor + + mock_agent = Mock() + mock_agent.name = "WorkflowAgent" + + mock_executor = Mock(spec=AgentExecutor) + mock_executor.agent = mock_agent + + mock_workflow = Mock() + mock_workflow.executors = {"WorkflowAgent": mock_executor} + + with ( + patch.object(AgentFunctionApp, "_setup_executor_activity"), + patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), + patch.object(AgentFunctionApp, "_setup_agent_functions"), + ): + app = AgentFunctionApp(workflow=mock_workflow) + + assert "WorkflowAgent" in app.agents + + def test_init_with_workflow_calls_setup_methods(self) -> None: + """Test that workflow setup methods are called.""" + mock_workflow = Mock() + mock_workflow.executors = {} + + with ( + patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec, + patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch, + ): + AgentFunctionApp(workflow=mock_workflow) + + setup_exec.assert_called_once() + setup_orch.assert_called_once() + + def test_init_without_workflow_does_not_call_workflow_setup(self) -> None: + """Test that workflow setup is not called when no workflow provided.""" + mock_agent = Mock() + mock_agent.name = "TestAgent" + + with ( + patch.object(AgentFunctionApp, "_setup_executor_activity") as setup_exec, + patch.object(AgentFunctionApp, "_setup_workflow_orchestration") as setup_orch, + ): + AgentFunctionApp(agents=[mock_agent]) + + setup_exec.assert_not_called() + setup_orch.assert_not_called() + + def test_build_status_url(self) -> None: + """Test _build_status_url constructs correct URL.""" + mock_workflow = Mock() + mock_workflow.executors = {} + + with ( + patch.object(AgentFunctionApp, "_setup_executor_activity"), + patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), + ): + app = AgentFunctionApp(workflow=mock_workflow) + + url = app._build_status_url("http://localhost:7071/api/workflow/run", "instance-123") + + assert url == "http://localhost:7071/api/workflow/status/instance-123" + + def test_build_status_url_handles_trailing_slash(self) -> None: + """Test _build_status_url handles URLs without /api/ correctly.""" + mock_workflow = Mock() + mock_workflow.executors = {} + + with ( + patch.object(AgentFunctionApp, "_setup_executor_activity"), + patch.object(AgentFunctionApp, "_setup_workflow_orchestration"), + ): + app = AgentFunctionApp(workflow=mock_workflow) + + url = app._build_status_url("http://localhost:7071/", "instance-456") + + assert "instance-456" in url + + if __name__ == "__main__": pytest.main([__file__, "-v", "--tb=short"]) diff --git a/python/packages/azurefunctions/tests/test_utils.py b/python/packages/azurefunctions/tests/test_utils.py new file mode 100644 index 0000000000..c95b663161 --- /dev/null +++ b/python/packages/azurefunctions/tests/test_utils.py @@ -0,0 +1,463 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for workflow utility functions.""" + +from dataclasses import dataclass +from unittest.mock import Mock + +import pytest +from agent_framework import ( + AgentExecutorRequest, + AgentExecutorResponse, + AgentRunResponse, + ChatMessage, + Message, + WorkflowOutputEvent, +) +from pydantic import BaseModel + +from agent_framework_azurefunctions._utils import ( + CapturingRunnerContext, + deserialize_value, + reconstruct_agent_executor_request, + reconstruct_agent_executor_response, + reconstruct_message_for_handler, + serialize_message, +) + + +class TestCapturingRunnerContext: + """Test suite for CapturingRunnerContext.""" + + @pytest.fixture + def context(self) -> CapturingRunnerContext: + """Create a fresh CapturingRunnerContext for each test.""" + return CapturingRunnerContext() + + @pytest.mark.asyncio + async def test_send_message_captures_message(self, context: CapturingRunnerContext) -> None: + """Test that send_message captures messages correctly.""" + message = Message(data="test data", target_id="target_1", source_id="source_1") + + await context.send_message(message) + + messages = await context.drain_messages() + assert "source_1" in messages + assert len(messages["source_1"]) == 1 + assert messages["source_1"][0].data == "test data" + + @pytest.mark.asyncio + async def test_send_multiple_messages_groups_by_source(self, context: CapturingRunnerContext) -> None: + """Test that messages are grouped by source_id.""" + msg1 = Message(data="msg1", target_id="target", source_id="source_a") + msg2 = Message(data="msg2", target_id="target", source_id="source_a") + msg3 = Message(data="msg3", target_id="target", source_id="source_b") + + await context.send_message(msg1) + await context.send_message(msg2) + await context.send_message(msg3) + + messages = await context.drain_messages() + assert len(messages["source_a"]) == 2 + assert len(messages["source_b"]) == 1 + + @pytest.mark.asyncio + async def test_drain_messages_clears_messages(self, context: CapturingRunnerContext) -> None: + """Test that drain_messages clears the message store.""" + message = Message(data="test", target_id="t", source_id="s") + await context.send_message(message) + + await context.drain_messages() # First drain + messages = await context.drain_messages() # Second drain + + assert messages == {} + + @pytest.mark.asyncio + async def test_has_messages_returns_correct_status(self, context: CapturingRunnerContext) -> None: + """Test has_messages returns correct boolean.""" + assert await context.has_messages() is False + + await context.send_message(Message(data="test", target_id="t", source_id="s")) + + assert await context.has_messages() is True + + @pytest.mark.asyncio + async def test_add_event_queues_event(self, context: CapturingRunnerContext) -> None: + """Test that add_event queues events correctly.""" + event = WorkflowOutputEvent(data="output", source_executor_id="exec_1") + + await context.add_event(event) + + events = await context.drain_events() + assert len(events) == 1 + assert isinstance(events[0], WorkflowOutputEvent) + assert events[0].data == "output" + + @pytest.mark.asyncio + async def test_drain_events_clears_queue(self, context: CapturingRunnerContext) -> None: + """Test that drain_events clears the event queue.""" + await context.add_event(WorkflowOutputEvent(data="test", source_executor_id="e")) + + await context.drain_events() # First drain + events = await context.drain_events() # Second drain + + assert events == [] + + @pytest.mark.asyncio + async def test_has_events_returns_correct_status(self, context: CapturingRunnerContext) -> None: + """Test has_events returns correct boolean.""" + assert await context.has_events() is False + + await context.add_event(WorkflowOutputEvent(data="test", source_executor_id="e")) + + assert await context.has_events() is True + + @pytest.mark.asyncio + async def test_next_event_waits_for_event(self, context: CapturingRunnerContext) -> None: + """Test that next_event returns queued events.""" + event = WorkflowOutputEvent(data="waited", source_executor_id="e") + await context.add_event(event) + + result = await context.next_event() + + assert result.data == "waited" + + def test_has_checkpointing_returns_false(self, context: CapturingRunnerContext) -> None: + """Test that checkpointing is not supported.""" + assert context.has_checkpointing() is False + + def test_is_streaming_returns_false_by_default(self, context: CapturingRunnerContext) -> None: + """Test streaming is disabled by default.""" + assert context.is_streaming() is False + + def test_set_streaming(self, context: CapturingRunnerContext) -> None: + """Test setting streaming mode.""" + context.set_streaming(True) + assert context.is_streaming() is True + + context.set_streaming(False) + assert context.is_streaming() is False + + def test_set_workflow_id(self, context: CapturingRunnerContext) -> None: + """Test setting workflow ID.""" + context.set_workflow_id("workflow-123") + assert context._workflow_id == "workflow-123" + + @pytest.mark.asyncio + async def test_reset_for_new_run_clears_state(self, context: CapturingRunnerContext) -> None: + """Test that reset_for_new_run clears all state.""" + await context.send_message(Message(data="test", target_id="t", source_id="s")) + await context.add_event(WorkflowOutputEvent(data="event", source_executor_id="e")) + context.set_streaming(True) + + context.reset_for_new_run() + + assert await context.has_messages() is False + assert await context.has_events() is False + assert context.is_streaming() is False + + @pytest.mark.asyncio + async def test_create_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None: + """Test that checkpointing methods raise NotImplementedError.""" + from agent_framework import SharedState + + with pytest.raises(NotImplementedError): + await context.create_checkpoint(SharedState(), 1) + + @pytest.mark.asyncio + async def test_load_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None: + """Test that load_checkpoint raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + await context.load_checkpoint("some-id") + + @pytest.mark.asyncio + async def test_apply_checkpoint_raises_not_implemented(self, context: CapturingRunnerContext) -> None: + """Test that apply_checkpoint raises NotImplementedError.""" + with pytest.raises(NotImplementedError): + await context.apply_checkpoint(Mock()) + + +class TestSerializeMessage: + """Test suite for serialize_message function.""" + + def test_serialize_none(self) -> None: + """Test serializing None.""" + assert serialize_message(None) is None + + def test_serialize_primitive_types(self) -> None: + """Test serializing primitive types.""" + assert serialize_message("hello") == "hello" + assert serialize_message(42) == 42 + assert serialize_message(3.14) == 3.14 + assert serialize_message(True) is True + + def test_serialize_list(self) -> None: + """Test serializing lists.""" + result = serialize_message([1, 2, 3]) + assert result == [1, 2, 3] + + def test_serialize_dict(self) -> None: + """Test serializing dicts.""" + result = serialize_message({"key": "value", "num": 42}) + assert result == {"key": "value", "num": 42} + + def test_serialize_dataclass(self) -> None: + """Test serializing dataclasses with type metadata.""" + + @dataclass + class TestData: + name: str + value: int + + data = TestData(name="test", value=123) + result = serialize_message(data) + + assert result["name"] == "test" + assert result["value"] == 123 + assert result["__type__"] == "TestData" + assert "__module__" in result + + def test_serialize_pydantic_model(self) -> None: + """Test serializing Pydantic models with type metadata.""" + + class TestModel(BaseModel): + title: str + count: int + + model = TestModel(title="Hello", count=5) + result = serialize_message(model) + + assert result["title"] == "Hello" + assert result["count"] == 5 + assert result["__type__"] == "TestModel" + assert "__module__" in result + + def test_serialize_nested_structures(self) -> None: + """Test serializing nested structures.""" + + @dataclass + class Inner: + x: int + + @dataclass + class Outer: + inner: Inner + items: list[int] + + outer = Outer(inner=Inner(x=10), items=[1, 2, 3]) + result = serialize_message(outer) + + assert result["__type__"] == "Outer" + # Nested dataclass is serialized via asdict, which doesn't add __type__ recursively + assert result["inner"]["x"] == 10 + assert result["items"] == [1, 2, 3] + + def test_serialize_object_with_to_dict(self) -> None: + """Test serializing objects with to_dict method.""" + message = ChatMessage(role="user", text="Hello") + result = serialize_message(message) + + # ChatMessage has to_dict() method which returns a specific structure + assert isinstance(result, dict) + assert "contents" in result # ChatMessage uses contents structure + + +class TestDeserializeValue: + """Test suite for deserialize_value function.""" + + def test_deserialize_non_dict_returns_original(self) -> None: + """Test that non-dict values are returned as-is.""" + assert deserialize_value("string") == "string" + assert deserialize_value(42) == 42 + assert deserialize_value([1, 2, 3]) == [1, 2, 3] + + def test_deserialize_dict_without_type_returns_original(self) -> None: + """Test that dicts without type metadata are returned as-is.""" + data = {"key": "value", "num": 42} + result = deserialize_value(data) + assert result == data + + def test_deserialize_agent_executor_request(self) -> None: + """Test deserializing AgentExecutorRequest.""" + data = { + "messages": [{"type": "chat_message", "role": "user", "contents": [{"type": "text", "text": "Hello"}]}], + "should_respond": True, + } + + result = deserialize_value(data) + + assert isinstance(result, AgentExecutorRequest) + assert len(result.messages) == 1 + assert result.should_respond is True + + def test_deserialize_agent_executor_response(self) -> None: + """Test deserializing AgentExecutorResponse.""" + data = { + "executor_id": "test_exec", + "agent_run_response": { + "type": "agent_run_response", + "messages": [ + {"type": "chat_message", "role": "assistant", "contents": [{"type": "text", "text": "Hi there"}]} + ], + }, + } + + result = deserialize_value(data) + + assert isinstance(result, AgentExecutorResponse) + assert result.executor_id == "test_exec" + + def test_deserialize_with_type_registry(self) -> None: + """Test deserializing with type registry.""" + + @dataclass + class CustomType: + name: str + + data = {"name": "test", "__type__": "CustomType"} + result = deserialize_value(data, type_registry={"CustomType": CustomType}) + + assert isinstance(result, CustomType) + assert result.name == "test" + + +class TestReconstructAgentExecutorRequest: + """Test suite for reconstruct_agent_executor_request function.""" + + def test_reconstruct_with_chat_messages(self) -> None: + """Test reconstructing request with ChatMessage dicts.""" + data = { + "messages": [ + {"type": "chat_message", "role": "user", "contents": [{"type": "text", "text": "Hello"}]}, + {"type": "chat_message", "role": "assistant", "contents": [{"type": "text", "text": "Hi"}]}, + ], + "should_respond": True, + } + + result = reconstruct_agent_executor_request(data) + + assert isinstance(result, AgentExecutorRequest) + assert len(result.messages) == 2 + assert result.should_respond is True + + def test_reconstruct_defaults_should_respond_to_true(self) -> None: + """Test that should_respond defaults to True.""" + data = {"messages": []} + + result = reconstruct_agent_executor_request(data) + + assert result.should_respond is True + + +class TestReconstructAgentExecutorResponse: + """Test suite for reconstruct_agent_executor_response function.""" + + def test_reconstruct_with_agent_run_response(self) -> None: + """Test reconstructing response with agent_run_response.""" + data = { + "executor_id": "my_executor", + "agent_run_response": { + "type": "agent_run_response", + "messages": [ + {"type": "chat_message", "role": "assistant", "contents": [{"type": "text", "text": "Response"}]} + ], + }, + "full_conversation": [], + } + + result = reconstruct_agent_executor_response(data) + + assert isinstance(result, AgentExecutorResponse) + assert result.executor_id == "my_executor" + assert isinstance(result.agent_run_response, AgentRunResponse) + + def test_reconstruct_with_full_conversation(self) -> None: + """Test reconstructing response with full_conversation.""" + data = { + "executor_id": "exec", + "agent_run_response": {"type": "agent_run_response", "messages": []}, + "full_conversation": [ + {"type": "chat_message", "role": "user", "contents": [{"type": "text", "text": "Q"}]}, + {"type": "chat_message", "role": "assistant", "contents": [{"type": "text", "text": "A"}]}, + ], + } + + result = reconstruct_agent_executor_response(data) + + assert result.full_conversation is not None + assert len(result.full_conversation) == 2 + + +class TestReconstructMessageForHandler: + """Test suite for reconstruct_message_for_handler function.""" + + def test_reconstruct_non_dict_returns_original(self) -> None: + """Test that non-dict messages are returned as-is.""" + assert reconstruct_message_for_handler("string", []) == "string" + assert reconstruct_message_for_handler(42, []) == 42 + + def test_reconstruct_agent_executor_response(self) -> None: + """Test reconstructing AgentExecutorResponse.""" + data = { + "executor_id": "exec", + "agent_run_response": {"type": "agent_run_response", "messages": []}, + } + + result = reconstruct_message_for_handler(data, [AgentExecutorResponse]) + + assert isinstance(result, AgentExecutorResponse) + + def test_reconstruct_agent_executor_request(self) -> None: + """Test reconstructing AgentExecutorRequest.""" + data = { + "messages": [{"type": "chat_message", "role": "user", "contents": [{"type": "text", "text": "Hi"}]}], + "should_respond": True, + } + + result = reconstruct_message_for_handler(data, [AgentExecutorRequest]) + + assert isinstance(result, AgentExecutorRequest) + + def test_reconstruct_with_type_metadata(self) -> None: + """Test reconstructing using __type__ metadata.""" + + @dataclass + class CustomMsg: + content: str + + # Serialize includes type metadata + serialized = serialize_message(CustomMsg(content="test")) + + result = reconstruct_message_for_handler(serialized, [CustomMsg]) + + assert isinstance(result, CustomMsg) + assert result.content == "test" + + def test_reconstruct_matches_dataclass_fields(self) -> None: + """Test reconstruction by matching dataclass field names.""" + + @dataclass + class MyData: + field_a: str + field_b: int + + data = {"field_a": "hello", "field_b": 42} + + result = reconstruct_message_for_handler(data, [MyData]) + + assert isinstance(result, MyData) + assert result.field_a == "hello" + assert result.field_b == 42 + + def test_reconstruct_returns_original_if_no_match(self) -> None: + """Test that original dict is returned if no type matches.""" + + @dataclass + class UnrelatedType: + completely_different_field: str + + data = {"some_key": "some_value"} + + result = reconstruct_message_for_handler(data, [UnrelatedType]) + + assert result == data diff --git a/python/packages/azurefunctions/tests/test_workflow.py b/python/packages/azurefunctions/tests/test_workflow.py new file mode 100644 index 0000000000..f401fc96c2 --- /dev/null +++ b/python/packages/azurefunctions/tests/test_workflow.py @@ -0,0 +1,379 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Unit tests for workflow orchestration functions.""" + +import json +from dataclasses import dataclass +from typing import Any + +from agent_framework import ( + AgentExecutorRequest, + AgentExecutorResponse, + AgentRunResponse, + ChatMessage, +) +from agent_framework._workflows._edge import ( + FanInEdgeGroup, + FanOutEdgeGroup, + SingleEdgeGroup, + SwitchCaseEdgeGroup, + SwitchCaseEdgeGroupCase, + SwitchCaseEdgeGroupDefault, +) + +from agent_framework_azurefunctions._workflow import ( + _extract_message_content, + _extract_message_content_from_dict, + build_agent_executor_response, + route_message_through_edge_groups, +) + + +class TestRouteMessageThroughEdgeGroups: + """Test suite for route_message_through_edge_groups function.""" + + def test_single_edge_group_routes_when_condition_matches(self) -> None: + """Test SingleEdgeGroup routes when condition is satisfied.""" + group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: True) + + targets = route_message_through_edge_groups([group], "src", "any message") + + assert targets == ["tgt"] + + def test_single_edge_group_does_not_route_when_condition_fails(self) -> None: + """Test SingleEdgeGroup does not route when condition fails.""" + group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: False) + + targets = route_message_through_edge_groups([group], "src", "any message") + + assert targets == [] + + def test_single_edge_group_ignores_different_source(self) -> None: + """Test SingleEdgeGroup ignores messages from different sources.""" + group = SingleEdgeGroup(source_id="src", target_id="tgt", condition=lambda m: True) + + targets = route_message_through_edge_groups([group], "other_src", "any message") + + assert targets == [] + + def test_switch_case_with_selection_func(self) -> None: + """Test SwitchCaseEdgeGroup uses selection_func.""" + + def select_first_target(msg: Any, targets: list[str]) -> list[str]: + return [targets[0]] + + group = SwitchCaseEdgeGroup( + source_id="src", + cases=[ + SwitchCaseEdgeGroupCase(condition=lambda m: True, target_id="target_a"), + SwitchCaseEdgeGroupDefault(target_id="target_b"), + ], + ) + # Manually set the selection function + group._selection_func = select_first_target + + targets = route_message_through_edge_groups([group], "src", "test") + + assert targets == ["target_a"] + + def test_switch_case_without_selection_func_broadcasts(self) -> None: + """Test SwitchCaseEdgeGroup without selection_func broadcasts to all.""" + group = SwitchCaseEdgeGroup( + source_id="src", + cases=[ + SwitchCaseEdgeGroupCase(condition=lambda m: True, target_id="target_a"), + SwitchCaseEdgeGroupDefault(target_id="target_b"), + ], + ) + group._selection_func = None + + targets = route_message_through_edge_groups([group], "src", "test") + + assert set(targets) == {"target_a", "target_b"} + + def test_fan_out_with_selection_func(self) -> None: + """Test FanOutEdgeGroup uses selection_func.""" + + def select_all(msg: Any, targets: list[str]) -> list[str]: + return targets + + group = FanOutEdgeGroup( + source_id="src", + target_ids=["fan_a", "fan_b", "fan_c"], + selection_func=select_all, + ) + + targets = route_message_through_edge_groups([group], "src", "broadcast") + + assert set(targets) == {"fan_a", "fan_b", "fan_c"} + + def test_fan_in_is_not_routed_directly(self) -> None: + """Test FanInEdgeGroup is handled separately (not routed here).""" + group = FanInEdgeGroup( + source_ids=["src_a", "src_b"], + target_id="aggregator", + ) + + # Fan-in should not add targets through this function + targets = route_message_through_edge_groups([group], "src_a", "message") + + assert targets == [] + + def test_multiple_edge_groups_aggregated(self) -> None: + """Test that targets from multiple edge groups are aggregated.""" + group1 = SingleEdgeGroup(source_id="src", target_id="t1", condition=lambda m: True) + group2 = SingleEdgeGroup(source_id="src", target_id="t2", condition=lambda m: True) + + targets = route_message_through_edge_groups([group1, group2], "src", "msg") + + assert set(targets) == {"t1", "t2"} + + +class TestBuildAgentExecutorResponse: + """Test suite for build_agent_executor_response function.""" + + def test_builds_response_with_text(self) -> None: + """Test building response with plain text.""" + response = build_agent_executor_response( + executor_id="my_executor", + response_text="Hello, world!", + structured_response=None, + previous_message="User input", + ) + + assert response.executor_id == "my_executor" + assert response.agent_run_response.text == "Hello, world!" + assert len(response.full_conversation) == 2 # User + Assistant + + def test_builds_response_with_structured_response(self) -> None: + """Test building response with structured JSON response.""" + structured = {"answer": 42, "reason": "because"} + + response = build_agent_executor_response( + executor_id="calc", + response_text="Original text", + structured_response=structured, + previous_message="Calculate", + ) + + # Structured response overrides text + assert response.agent_run_response.text == json.dumps(structured) + + def test_conversation_includes_previous_string_message(self) -> None: + """Test that string previous_message is included in conversation.""" + response = build_agent_executor_response( + executor_id="exec", + response_text="Response", + structured_response=None, + previous_message="User said this", + ) + + assert len(response.full_conversation) == 2 + assert response.full_conversation[0].role.value == "user" + assert response.full_conversation[0].text == "User said this" + assert response.full_conversation[1].role.value == "assistant" + + def test_conversation_extends_previous_agent_executor_response(self) -> None: + """Test that previous AgentExecutorResponse's conversation is extended.""" + # Create a previous response with conversation history + previous = AgentExecutorResponse( + executor_id="prev", + agent_run_response=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Previous")]), + full_conversation=[ + ChatMessage(role="user", text="First"), + ChatMessage(role="assistant", text="Previous"), + ], + ) + + response = build_agent_executor_response( + executor_id="current", + response_text="Current response", + structured_response=None, + previous_message=previous, + ) + + # Should have 3 messages: First + Previous + Current + assert len(response.full_conversation) == 3 + assert response.full_conversation[0].text == "First" + assert response.full_conversation[1].text == "Previous" + assert response.full_conversation[2].text == "Current response" + + +class TestExtractMessageContent: + """Test suite for _extract_message_content function.""" + + def test_extract_from_string(self) -> None: + """Test extracting content from plain string.""" + result = _extract_message_content("Hello, world!") + + assert result == "Hello, world!" + + def test_extract_from_agent_executor_response_with_text(self) -> None: + """Test extracting from AgentExecutorResponse with text.""" + response = AgentExecutorResponse( + executor_id="exec", + agent_run_response=AgentRunResponse(messages=[ChatMessage(role="assistant", text="Response text")]), + ) + + result = _extract_message_content(response) + + assert result == "Response text" + + def test_extract_from_agent_executor_response_with_messages(self) -> None: + """Test extracting from AgentExecutorResponse with messages.""" + response = AgentExecutorResponse( + executor_id="exec", + agent_run_response=AgentRunResponse( + messages=[ + ChatMessage(role="user", text="First"), + ChatMessage(role="assistant", text="Last message"), + ] + ), + ) + + result = _extract_message_content(response) + + # AgentRunResponse.text concatenates all message texts + assert result == "FirstLast message" + + def test_extract_from_agent_executor_request(self) -> None: + """Test extracting from AgentExecutorRequest.""" + request = AgentExecutorRequest( + messages=[ + ChatMessage(role="user", text="First"), + ChatMessage(role="user", text="Last request"), + ] + ) + + result = _extract_message_content(request) + + assert result == "Last request" + + def test_extract_from_dict_agent_executor_request(self) -> None: + """Test extracting from serialized AgentExecutorRequest dict.""" + msg_dict = { + "messages": [ + { + "type": "chat_message", + "contents": [{"type": "text", "text": "Hello from dict"}], + } + ] + } + + result = _extract_message_content(msg_dict) + + assert result == "Hello from dict" + + def test_extract_returns_empty_for_unknown_type(self) -> None: + """Test that unknown types return empty string.""" + result = _extract_message_content(12345) + + assert result == "" + + +class TestExtractMessageContentFromDict: + """Test suite for _extract_message_content_from_dict function.""" + + def test_extract_from_messages_with_contents(self) -> None: + """Test extracting from messages with contents structure.""" + msg_dict = {"messages": [{"contents": [{"type": "text", "text": "Content text"}]}]} + + result = _extract_message_content_from_dict(msg_dict) + + assert result == "Content text" + + def test_extract_from_messages_with_direct_text(self) -> None: + """Test extracting from messages with direct text field.""" + msg_dict = {"messages": [{"text": "Direct text"}]} + + result = _extract_message_content_from_dict(msg_dict) + + assert result == "Direct text" + + def test_extract_from_agent_run_response(self) -> None: + """Test extracting from agent_run_response dict.""" + msg_dict = {"agent_run_response": {"text": "Response text"}} + + result = _extract_message_content_from_dict(msg_dict) + + assert result == "Response text" + + def test_extract_from_agent_run_response_with_messages(self) -> None: + """Test extracting from agent_run_response with messages.""" + msg_dict = {"agent_run_response": {"messages": [{"contents": [{"type": "text", "text": "Nested content"}]}]}} + + result = _extract_message_content_from_dict(msg_dict) + + assert result == "Nested content" + + def test_extract_returns_empty_for_empty_dict(self) -> None: + """Test that empty dict returns empty string.""" + result = _extract_message_content_from_dict({}) + + assert result == "" + + def test_extract_returns_empty_for_empty_messages(self) -> None: + """Test that empty messages list returns empty string.""" + result = _extract_message_content_from_dict({"messages": []}) + + assert result == "" + + +class TestEdgeGroupIntegration: + """Integration tests for edge group routing with realistic scenarios.""" + + def test_conditional_routing_by_message_type(self) -> None: + """Test routing based on message content/type.""" + + @dataclass + class SpamResult: + is_spam: bool + reason: str + + def is_spam_condition(msg: Any) -> bool: + if isinstance(msg, SpamResult): + return msg.is_spam + return False + + def is_not_spam_condition(msg: Any) -> bool: + if isinstance(msg, SpamResult): + return not msg.is_spam + return False + + spam_group = SingleEdgeGroup( + source_id="detector", + target_id="spam_handler", + condition=is_spam_condition, + ) + legit_group = SingleEdgeGroup( + source_id="detector", + target_id="email_handler", + condition=is_not_spam_condition, + ) + + # Test spam message + spam_msg = SpamResult(is_spam=True, reason="Suspicious content") + targets = route_message_through_edge_groups([spam_group, legit_group], "detector", spam_msg) + assert targets == ["spam_handler"] + + # Test legitimate message + legit_msg = SpamResult(is_spam=False, reason="Clean") + targets = route_message_through_edge_groups([spam_group, legit_group], "detector", legit_msg) + assert targets == ["email_handler"] + + def test_fan_out_to_multiple_workers(self) -> None: + """Test fan-out to multiple parallel workers.""" + + def select_all_workers(msg: Any, targets: list[str]) -> list[str]: + return targets + + group = FanOutEdgeGroup( + source_id="coordinator", + target_ids=["worker_1", "worker_2", "worker_3"], + selection_func=select_all_workers, + ) + + targets = route_message_through_edge_groups([group], "coordinator", {"task": "process"}) + + assert len(targets) == 3 + assert set(targets) == {"worker_1", "worker_2", "worker_3"} diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 4e0d2058ad..7a6ecea35b 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -111,6 +111,11 @@ def workflow_output_types(self) -> list[type[Any]]: return [AgentRunResponse] return [] + @property + def agent(self) -> AgentProtocol: + """Get the underlying agent wrapped by this executor.""" + return self._agent + @property def description(self) -> str | None: """Get the description of the underlying agent.""" diff --git a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/demo.http b/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/demo.http index 42f93b8543..28231a08a8 100644 --- a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/demo.http +++ b/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/demo.http @@ -20,7 +20,7 @@ Content-Type: application/json ### Replace INSTANCE_ID_GOES_HERE below with the value returned from the POST call -@instanceId= +@instanceId=ccf3950407b5496893df93d1357a5afa ### Check the status of the orchestration GET http://localhost:7071/api/hitl/status/{{instanceId}} diff --git a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py b/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py index 08a14ffe11..8985b0245e 100644 --- a/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py +++ b/python/samples/getting_started/azure_functions/07_single_agent_orchestration_hitl/function_app.py @@ -62,7 +62,7 @@ def _create_writer_agent() -> Any: # 3. Activities encapsulate external work for review notifications and publishing. @app.activity_trigger(input_name="content") -def notify_user_for_approval(content: Any) -> None: +def notify_user_for_approval(content: dict) -> None: model = GeneratedContent.model_validate(content) logger.info("NOTIFICATION: Please review the following content for approval:") logger.info("Title: %s", model.title or "(untitled)") @@ -71,7 +71,7 @@ def notify_user_for_approval(content: Any) -> None: @app.activity_trigger(input_name="content") -def publish_content(content: Any) -> None: +def publish_content(content: dict) -> None: model = GeneratedContent.model_validate(content) logger.info("PUBLISHING: Content has been published successfully:") logger.info("Title: %s", model.title or "(untitled)") diff --git a/python/samples/getting_started/azure_functions/09_workflow_shared_state/.gitignore b/python/samples/getting_started/azure_functions/09_workflow_shared_state/.gitignore new file mode 100644 index 0000000000..560ff95106 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_workflow_shared_state/.gitignore @@ -0,0 +1,18 @@ +# Local settings +local.settings.json +.env + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# Azure Functions +bin/ +obj/ +.python_packages/ + +# IDE +.vscode/ +.idea/ diff --git a/python/samples/getting_started/azure_functions/09_workflow_shared_state/README.md b/python/samples/getting_started/azure_functions/09_workflow_shared_state/README.md new file mode 100644 index 0000000000..bd6e33c916 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_workflow_shared_state/README.md @@ -0,0 +1,99 @@ +# Workflow with SharedState Sample + +This sample demonstrates running **Agent Framework workflows with SharedState** in Azure Durable Functions. + +## Overview + +This sample shows how to use `AgentFunctionApp` to execute a `WorkflowBuilder` workflow that uses SharedState to pass data between executors. SharedState is a local dictionary maintained by the orchestration that allows executors to share data across workflow steps. + +## What This Sample Demonstrates + +1. **Workflow Execution** - Running `WorkflowBuilder` workflows in Azure Durable Functions +2. **SharedState APIs** - Using `ctx.set_shared_state()` and `ctx.get_shared_state()` to share data +3. **Conditional Routing** - Routing messages based on spam detection results +4. **Agent + Executor Composition** - Combining AI agents with non-AI function executors + +## Workflow Architecture + +``` +store_email → spam_detector (agent) → to_detection_result → [branch]: + ├── If spam: handle_spam → yield "Email marked as spam: {reason}" + └── If not spam: submit_to_email_assistant → email_assistant (agent) → finalize_and_send → yield "Email sent: {response}" +``` + +### SharedState Usage by Executor + +| Executor | SharedState Operations | +|----------|----------------------| +| `store_email` | `set_shared_state("email:{id}", email)`, `set_shared_state("current_email_id", id)` | +| `to_detection_result` | `get_shared_state("current_email_id")` | +| `submit_to_email_assistant` | `get_shared_state("email:{id}")` | + +SharedState allows executors to pass large payloads (like email content) by reference rather than through message routing. + +## Prerequisites + +1. **Azure OpenAI** - Endpoint and deployment configured +2. **Azurite** - For local storage emulation + +## Setup + +1. Copy `local.settings.json.sample` to `local.settings.json` and configure: + ```json + { + "Values": { + "AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "gpt-4o" + } + } + ``` + +2. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +3. Start Azurite: + ```bash + azurite --silent + ``` + +4. Run the function app: + ```bash + func start + ``` + +## Testing + +Use the `demo.http` file with REST Client extension or curl: + +### Test Spam Email +```bash +curl -X POST http://localhost:7071/api/workflow/run \ + -H "Content-Type: application/json" \ + -d '"URGENT! You have won $1,000,000! Click here to claim!"' +``` + +### Test Legitimate Email +```bash +curl -X POST http://localhost:7071/api/workflow/run \ + -H "Content-Type: application/json" \ + -d '"Hi team, reminder about our meeting tomorrow at 10 AM."' +``` + +## Expected Output + +**Spam email:** +``` +Email marked as spam: This email exhibits spam characteristics including urgent language, unrealistic claims of monetary winnings, and requests to click suspicious links. +``` + +**Legitimate email:** +``` +Email sent: Hi, Thank you for the reminder about the sprint planning meeting tomorrow at 10 AM. I will review the agenda and come prepared with my updates. See you then! +``` + +## Related Samples + +- `10_workflow_no_shared_state` - Workflow execution without SharedState usage +- `06_multi_agent_orchestration_conditionals` - Manual Durable Functions orchestration with agents diff --git a/python/samples/getting_started/azure_functions/09_workflow_shared_state/demo.http b/python/samples/getting_started/azure_functions/09_workflow_shared_state/demo.http new file mode 100644 index 0000000000..48b6a73f72 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_workflow_shared_state/demo.http @@ -0,0 +1,31 @@ +@endpoint = http://localhost:7071 + +### Start the workflow with a spam email +POST {{endpoint}}/api/workflow/run +Content-Type: application/json + +"URGENT! You have won $1,000,000! Click here to claim your prize now before it expires!" + +### Start the workflow with a legitimate email +POST {{endpoint}}/api/workflow/run +Content-Type: application/json + +"Hi team, just a reminder about the sprint planning meeting tomorrow at 10 AM. Please review the agenda items in Jira before the call." + +### Start the workflow with another legitimate email +POST {{endpoint}}/api/workflow/run +Content-Type: application/json + +"Hello, I wanted to follow up on our conversation from last week regarding the project timeline. Could we schedule a brief call this afternoon to discuss the next steps?" + +### Start the workflow with a phishing attempt +POST {{endpoint}}/api/workflow/run +Content-Type: application/json + +"Dear Customer, Your account has been compromised! Click this link immediately to secure your account: http://totallylegit.suspicious.com/secure" + +### Check workflow status (replace {instanceId} with actual instance ID from response) +GET {{endpoint}}/runtime/webhooks/durabletask/instances/{instanceId} + +### Purge all orchestration instances (use for cleanup) +POST {{endpoint}}/runtime/webhooks/durabletask/instances/purge?createdTimeFrom=2020-01-01T00:00:00Z&createdTimeTo=2030-12-31T23:59:59Z diff --git a/python/samples/getting_started/azure_functions/09_workflow_shared_state/function_app.py b/python/samples/getting_started/azure_functions/09_workflow_shared_state/function_app.py new file mode 100644 index 0000000000..bf38dfc72b --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_workflow_shared_state/function_app.py @@ -0,0 +1,294 @@ +# Copyright (c) Microsoft. All rights reserved. +""" +Sample: Shared state with agents and conditional routing. + +Store an email once by id, classify it with a detector agent, then either draft a reply with an assistant +agent or finish with a spam notice. Stream events as the workflow runs. + +Purpose: +Show how to: +- Use shared state to decouple large payloads from messages and pass around lightweight references. +- Enforce structured agent outputs with Pydantic models via response_format for robust parsing. +- Route using conditional edges based on a typed intermediate DetectionResult. +- Compose agent backed executors with function style executors and yield the final output when the workflow completes. + +Prerequisites: +- Azure OpenAI configured for AzureOpenAIChatClient with required environment variables. +- Authentication via azure-identity. Use DefaultAzureCredential and run az login before executing the sample. +- Familiarity with WorkflowBuilder, executors, conditional edges, and streaming runs. +""" + +import logging +import os +from dataclasses import dataclass +from typing import Any +from uuid import uuid4 + +from agent_framework import ( + AgentExecutorRequest, + AgentExecutorResponse, + ChatMessage, + Role, + Workflow, + WorkflowBuilder, + WorkflowContext, + executor, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential +from pydantic import BaseModel +from typing_extensions import Never + +from agent_framework_azurefunctions import AgentFunctionApp + +logger = logging.getLogger(__name__) + +# Environment variable names +AZURE_OPENAI_ENDPOINT_ENV = "AZURE_OPENAI_ENDPOINT" +AZURE_OPENAI_DEPLOYMENT_ENV = "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" +AZURE_OPENAI_API_KEY_ENV = "AZURE_OPENAI_API_KEY" + +EMAIL_STATE_PREFIX = "email:" +CURRENT_EMAIL_ID_KEY = "current_email_id" + + +class DetectionResultAgent(BaseModel): + """Structured output returned by the spam detection agent.""" + + is_spam: bool + reason: str + + +class EmailResponse(BaseModel): + """Structured output returned by the email assistant agent.""" + + response: str + + +@dataclass +class DetectionResult: + """Internal detection result enriched with the shared state email_id for later lookups.""" + + is_spam: bool + reason: str + email_id: str + + +@dataclass +class Email: + """In memory record stored in shared state to avoid re-sending large bodies on edges.""" + + email_id: str + email_content: str + + +def get_condition(expected_result: bool): + """Create a condition predicate for DetectionResult.is_spam. + + Contract: + - If the message is not a DetectionResult, allow it to pass to avoid accidental dead ends. + - Otherwise, return True only when is_spam matches expected_result. + """ + + def condition(message: Any) -> bool: + if not isinstance(message, DetectionResult): + return True + return message.is_spam == expected_result + + return condition + + +@executor(id="store_email") +async def store_email(email_text: str, ctx: WorkflowContext[AgentExecutorRequest]) -> None: + """Persist the raw email content in shared state and trigger spam detection. + + Responsibilities: + - Generate a unique email_id (UUID) for downstream retrieval. + - Store the Email object under a namespaced key and set the current id pointer. + - Emit an AgentExecutorRequest asking the detector to respond. + """ + new_email = Email(email_id=str(uuid4()), email_content=email_text) + await ctx.set_shared_state(f"{EMAIL_STATE_PREFIX}{new_email.email_id}", new_email) + await ctx.set_shared_state(CURRENT_EMAIL_ID_KEY, new_email.email_id) + + await ctx.send_message( + AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=new_email.email_content)], should_respond=True) + ) + + +@executor(id="to_detection_result") +async def to_detection_result(response: AgentExecutorResponse, ctx: WorkflowContext[DetectionResult]) -> None: + """Parse spam detection JSON into a structured model and enrich with email_id. + + Steps: + 1) Validate the agent's JSON output into DetectionResultAgent. + 2) Retrieve the current email_id from shared state. + 3) Send a typed DetectionResult for conditional routing. + """ + parsed = DetectionResultAgent.model_validate_json(response.agent_run_response.text) + email_id: str = await ctx.get_shared_state(CURRENT_EMAIL_ID_KEY) + await ctx.send_message(DetectionResult(is_spam=parsed.is_spam, reason=parsed.reason, email_id=email_id)) + + +@executor(id="submit_to_email_assistant") +async def submit_to_email_assistant(detection: DetectionResult, ctx: WorkflowContext[AgentExecutorRequest]) -> None: + """Forward non spam email content to the drafting agent. + + Guard: + - This path should only receive non spam. Raise if misrouted. + """ + if detection.is_spam: + raise RuntimeError("This executor should only handle non-spam messages.") + + # Load the original content by id from shared state and forward it to the assistant. + email: Email = await ctx.get_shared_state(f"{EMAIL_STATE_PREFIX}{detection.email_id}") + await ctx.send_message( + AgentExecutorRequest(messages=[ChatMessage(Role.USER, text=email.email_content)], should_respond=True) + ) + + +@executor(id="finalize_and_send") +async def finalize_and_send(response: AgentExecutorResponse, ctx: WorkflowContext[Never, str]) -> None: + """Validate the drafted reply and yield the final output.""" + parsed = EmailResponse.model_validate_json(response.agent_run_response.text) + await ctx.yield_output(f"Email sent: {parsed.response}") + + +@executor(id="handle_spam") +async def handle_spam(detection: DetectionResult, ctx: WorkflowContext[Never, str]) -> None: + """Yield output describing why the email was marked as spam.""" + if detection.is_spam: + await ctx.yield_output(f"Email marked as spam: {detection.reason}") + else: + raise RuntimeError("This executor should only handle spam messages.") + + +# ============================================================================ +# Workflow Creation +# ============================================================================ + + +def _build_client_kwargs() -> dict[str, Any]: + """Build Azure OpenAI client configuration from environment variables.""" + endpoint = os.getenv(AZURE_OPENAI_ENDPOINT_ENV) + if not endpoint: + raise RuntimeError(f"{AZURE_OPENAI_ENDPOINT_ENV} environment variable is required.") + + deployment = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) + if not deployment: + raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") + + client_kwargs: dict[str, Any] = { + "endpoint": endpoint, + "deployment_name": deployment, + } + + api_key = os.getenv(AZURE_OPENAI_API_KEY_ENV) + if api_key: + client_kwargs["api_key"] = api_key + else: + client_kwargs["credential"] = AzureCliCredential() + + return client_kwargs + + +def _create_workflow() -> Workflow: + """Create the email classification workflow with conditional routing.""" + client_kwargs = _build_client_kwargs() + chat_client = AzureOpenAIChatClient(**client_kwargs) + + spam_detection_agent = chat_client.create_agent( + instructions=( + "You are a spam detection assistant that identifies spam emails. " + "Always return JSON with fields is_spam (bool) and reason (string)." + ), + response_format=DetectionResultAgent, + name="spam_detection_agent", + ) + + email_assistant_agent = chat_client.create_agent( + instructions=( + "You are an email assistant that helps users draft responses to emails with professionalism. " + "Return JSON with a single field 'response' containing the drafted reply." + ), + response_format=EmailResponse, + name="email_assistant_agent", + ) + + # Build the workflow graph with conditional edges. + # Flow: + # store_email -> spam_detection_agent -> to_detection_result -> branch: + # False -> submit_to_email_assistant -> email_assistant_agent -> finalize_and_send + # True -> handle_spam + workflow = ( + WorkflowBuilder() + .set_start_executor(store_email) + .add_edge(store_email, spam_detection_agent) + .add_edge(spam_detection_agent, to_detection_result) + .add_edge(to_detection_result, submit_to_email_assistant, condition=get_condition(False)) + .add_edge(to_detection_result, handle_spam, condition=get_condition(True)) + .add_edge(submit_to_email_assistant, email_assistant_agent) + .add_edge(email_assistant_agent, finalize_and_send) + .build() + ) + + return workflow + + +# ============================================================================ +# Application Entry Point +# ============================================================================ + + +def launch(durable: bool = True) -> AgentFunctionApp | None: + """Launch the function app or DevUI. + + Args: + durable: If True, returns AgentFunctionApp for Azure Functions. + If False, launches DevUI for local MAF development. + """ + if durable: + # Azure Functions mode with Durable Functions + # SharedState is enabled by default, which this sample requires for storing emails + workflow = _create_workflow() + app = AgentFunctionApp(workflow=workflow, enable_health_check=True) + return app + else: + # Pure MAF mode with DevUI for local development + from pathlib import Path + + from agent_framework.devui import serve + from dotenv import load_dotenv + + env_path = Path(__file__).parent / ".env" + load_dotenv(dotenv_path=env_path) + + logger.info("Starting Workflow Shared State Sample in MAF mode") + logger.info("Available at: http://localhost:8096") + logger.info("\nThis workflow demonstrates:") + logger.info("- Shared state to decouple large payloads from messages") + logger.info("- Structured agent outputs with Pydantic models") + logger.info("- Conditional routing based on detection results") + logger.info("\nFlow: store_email -> spam_detection -> branch (spam/not spam)") + + workflow = _create_workflow() + serve(entities=[workflow], port=8096, auto_open=True) + + return None + + +# Default: Azure Functions mode +# Run with `python function_app.py --maf` for pure MAF mode with DevUI +app = launch(durable=True) + + +if __name__ == "__main__": + import sys + + if "--maf" in sys.argv: + # Run in pure MAF mode with DevUI + launch(durable=False) + else: + print("Usage: python function_app.py --maf") + print(" --maf Run in pure MAF mode with DevUI (http://localhost:8096)") + print("\nFor Azure Functions mode, use: func start") diff --git a/python/samples/getting_started/azure_functions/09_workflow_shared_state/host.json b/python/samples/getting_started/azure_functions/09_workflow_shared_state/host.json new file mode 100644 index 0000000000..292562af8e --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_workflow_shared_state/host.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "hubName": "%TASKHUB_NAME%", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/python/samples/getting_started/azure_functions/09_workflow_shared_state/local.settings.json.sample b/python/samples/getting_started/azure_functions/09_workflow_shared_state/local.settings.json.sample new file mode 100644 index 0000000000..69c08a3386 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_workflow_shared_state/local.settings.json.sample @@ -0,0 +1,11 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "TASKHUB_NAME": "default", + "FUNCTIONS_WORKER_RUNTIME": "python", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "" + } +} diff --git a/python/samples/getting_started/azure_functions/09_workflow_shared_state/requirements.txt b/python/samples/getting_started/azure_functions/09_workflow_shared_state/requirements.txt new file mode 100644 index 0000000000..5739f93aa3 --- /dev/null +++ b/python/samples/getting_started/azure_functions/09_workflow_shared_state/requirements.txt @@ -0,0 +1,3 @@ +agent-framework-azurefunctions +azure-identity +agents-maf \ No newline at end of file diff --git a/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/.env.sample b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/.env.sample new file mode 100644 index 0000000000..cf8fe3d05c --- /dev/null +++ b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/.env.sample @@ -0,0 +1,4 @@ +# Azure OpenAI Configuration +AZURE_OPENAI_ENDPOINT=https://.openai.azure.com/ +AZURE_OPENAI_CHAT_DEPLOYMENT_NAME= +AZURE_OPENAI_API_KEY= diff --git a/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/.gitignore b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/.gitignore new file mode 100644 index 0000000000..1d5b48c35f --- /dev/null +++ b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/.gitignore @@ -0,0 +1,2 @@ +.env +local.settings.json diff --git a/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/README.md b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/README.md new file mode 100644 index 0000000000..f5f77f3c91 --- /dev/null +++ b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/README.md @@ -0,0 +1,159 @@ +# Workflow Execution Sample + +This sample demonstrates running **Agent Framework workflows** in Azure Durable Functions without using SharedState. + +## Overview + +This sample shows how to use `AgentFunctionApp` with a `WorkflowBuilder` workflow. The workflow is passed directly to `AgentFunctionApp`, which orchestrates execution using Durable Functions: + +```python +workflow = _create_workflow() # Build the workflow graph +app = AgentFunctionApp(workflow=workflow) +``` + +This approach provides durable, fault-tolerant workflow execution with minimal code. + +## What This Sample Demonstrates + +1. **Workflow Registration** - Pass a `Workflow` directly to `AgentFunctionApp` +2. **Durable Execution** - Workflow executes with Durable Functions durability and scalability +3. **Conditional Routing** - Route messages based on spam detection (is_spam → spam handler, not spam → email assistant) +4. **Agent + Executor Composition** - Combine AI agents with non-AI executor classes + +## Workflow Architecture + +``` +SpamDetectionAgent → [branch based on is_spam]: + ├── If spam: SpamHandlerExecutor → yield "Email marked as spam: {reason}" + └── If not spam: EmailAssistantAgent → EmailSenderExecutor → yield "Email sent: {response}" +``` + +### Components + +| Component | Type | Description | +|-----------|------|-------------| +| `SpamDetectionAgent` | AI Agent | Analyzes emails for spam indicators | +| `EmailAssistantAgent` | AI Agent | Drafts professional email responses | +| `SpamHandlerExecutor` | Executor | Handles spam emails (non-AI) | +| `EmailSenderExecutor` | Executor | Sends email responses (non-AI) | + +## Prerequisites + +1. **Azure OpenAI** - Endpoint and deployment configured +2. **Azurite** - For local storage emulation + +## Setup + +1. Copy configuration files: + ```bash + cp local.settings.json.sample local.settings.json + ``` + +2. Configure `local.settings.json`: + +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +4. Start Azurite: + ```bash + azurite --silent + ``` + +5. Run the function app: + ```bash + func start + ``` + +## Testing + +Use the `demo.http` file with REST Client extension or curl: + +### Test Spam Email +```bash +curl -X POST http://localhost:7071/api/workflow/run \ + -H "Content-Type: application/json" \ + -d '{"email_id": "test-001", "email_content": "URGENT! You have won $1,000,000! Click here!"}' +``` + +### Test Legitimate Email +```bash +curl -X POST http://localhost:7071/api/workflow/run \ + -H "Content-Type: application/json" \ + -d '{"email_id": "test-002", "email_content": "Hi team, reminder about our meeting tomorrow at 10 AM."}' +``` + +### Check Status +```bash +curl http://localhost:7071/api/workflow/status/{instanceId} +``` + +## Expected Output + +**Spam email:** +``` +Email marked as spam: This email exhibits spam characteristics including urgent language, unrealistic claims of monetary winnings, and requests to click suspicious links. +``` + +**Legitimate email:** +``` +Email sent: Hi, Thank you for the reminder about the sprint planning meeting tomorrow at 10 AM. I will be there. +``` + +## Code Highlights + +### Creating the Workflow + +```python +workflow = ( + WorkflowBuilder() + .set_start_executor(spam_agent) + .add_switch_case_edge_group( + spam_agent, + [ + Case(condition=is_spam_detected, target=spam_handler), + Default(target=email_agent), + ], + ) + .add_edge(email_agent, email_sender) + .build() +) +``` + +### Registering with AgentFunctionApp + +```python +app = AgentFunctionApp(workflow=workflow, enable_health_check=True) +``` + +### Executor Classes + +```python +class SpamHandlerExecutor(Executor): + @handler + async def handle_spam_result( + self, + agent_response: AgentExecutorResponse, + ctx: WorkflowContext[Never, str], + ) -> None: + spam_result = SpamDetectionResult.model_validate_json(agent_response.agent_run_response.text) + await ctx.yield_output(f"Email marked as spam: {spam_result.reason}") +``` + +## Standalone Mode (DevUI) + +This sample also supports running standalone for local development: + +```python +# Change launch(durable=True) to launch(durable=False) in function_app.py +# Then run: +python function_app.py +``` + +This starts the DevUI at `http://localhost:8094` for interactive testing. + +## Related Samples + +- `09_workflow_shared_state` - Workflow with SharedState for passing data between executors +- `06_multi_agent_orchestration_conditionals` - Manual Durable Functions orchestration with agents diff --git a/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/demo.http b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/demo.http new file mode 100644 index 0000000000..2c81ddc9bc --- /dev/null +++ b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/demo.http @@ -0,0 +1,32 @@ +### Start Workflow Orchestration - Spam Email +POST http://localhost:7071/api/workflow/run +Content-Type: application/json + +{ + "email_id": "email-001", + "email_content": "URGENT! You've won $1,000,000! Click here immediately to claim your prize! Limited time offer - act now!" +} + +### + +### Start Workflow Orchestration - Legitimate Email +POST http://localhost:7071/api/workflow/run +Content-Type: application/json + +{ + "email_id": "email-002", + "email_content": "Hi team, just a reminder about our sprint planning meeting tomorrow at 10 AM. Please review the agenda in Jira." +} + +### + +### Get Workflow Status +# Replace {instanceId} with the actual instance ID from the start response +GET http://localhost:7071/api/workflow/status/{instanceId} + +### + +### Health Check +GET http://localhost:7071/api/health + +### diff --git a/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/function_app.py b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/function_app.py new file mode 100644 index 0000000000..b55fef58b8 --- /dev/null +++ b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/function_app.py @@ -0,0 +1,244 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Workflow Execution within Durable Functions Orchestrator. + +This sample demonstrates running agent framework WorkflowBuilder workflows inside +a Durable Functions orchestrator by manually traversing the workflow graph and +delegating execution to Durable Entities (for agents) and Activities (for other logic). + +Key architectural points: +- AgentFunctionApp registers agents as DurableAIAgents. +- WorkflowBuilder uses `DurableAgentDefinition` (a placeholder) to define the graph. +- The orchestrator (`workflow_orchestration`) iterates through the workflow graph. +- When an agent node is encountered, it calls the corresponding `DurableAIAgent` entity. +- When a standard executor node is encountered, it calls an Activity (`ExecuteExecutor`). + +This approach allows using the rich structure of `WorkflowBuilder` while leveraging +the statefulness and durability of `DurableAIAgent`s. +""" + +import logging +import os +from typing import Any, Dict + +from pathlib import Path +from agent_framework import ( + AgentExecutorResponse, + Case, + Default, + Executor, + Workflow, + WorkflowBuilder, + WorkflowContext, + handler, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential +from pydantic import BaseModel +from agent_framework_azurefunctions import AgentFunctionApp +from typing_extensions import Never + +logger = logging.getLogger(__name__) + +AZURE_OPENAI_ENDPOINT_ENV = "AZURE_OPENAI_ENDPOINT" +AZURE_OPENAI_DEPLOYMENT_ENV = "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" +AZURE_OPENAI_API_KEY_ENV = "AZURE_OPENAI_API_KEY" +SPAM_AGENT_NAME = "SpamDetectionAgent" +EMAIL_AGENT_NAME = "EmailAssistantAgent" + +SPAM_DETECTION_INSTRUCTIONS = ( + "You are a spam detection assistant that identifies spam emails.\n\n" + "Analyze the email content for spam indicators including:\n" + "1. Suspicious language (urgent, limited time, act now, free money, etc.)\n" + "2. Suspicious links or requests for personal information\n" + "3. Poor grammar or spelling\n" + "4. Requests for money or financial information\n" + "5. Impersonation attempts\n\n" + "Return a JSON response with:\n" + "- is_spam: boolean indicating if it's spam\n" + "- confidence: float between 0.0 and 1.0\n" + "- reason: detailed explanation of your classification" +) + +EMAIL_ASSISTANT_INSTRUCTIONS = ( + "You are an email assistant that helps users draft responses to legitimate emails.\n\n" + "When you receive an email that has been verified as legitimate:\n" + "1. Draft a professional and appropriate response\n" + "2. Match the tone and formality of the original email\n" + "3. Be helpful and courteous\n" + "4. Keep the response concise but complete\n\n" + "Return a JSON response with:\n" + "- response: the drafted email response" +) + + +class SpamDetectionResult(BaseModel): + is_spam: bool + confidence: float + reason: str + + +class EmailResponse(BaseModel): + response: str + + +class EmailPayload(BaseModel): + email_id: str + email_content: str + + +def _build_client_kwargs() -> dict[str, Any]: + endpoint = os.getenv(AZURE_OPENAI_ENDPOINT_ENV) + if not endpoint: + raise RuntimeError(f"{AZURE_OPENAI_ENDPOINT_ENV} environment variable is required.") + + deployment = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) + if not deployment: + raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") + + client_kwargs: dict[str, Any] = { + "endpoint": endpoint, + "deployment_name": deployment, + } + + api_key = os.getenv(AZURE_OPENAI_API_KEY_ENV) + if api_key: + client_kwargs["api_key"] = api_key + else: + client_kwargs["credential"] = AzureCliCredential() + + return client_kwargs + + +# Executors for non-AI activities (defined at module level) +class SpamHandlerExecutor(Executor): + """Executor that handles spam emails (non-AI activity).""" + + @handler + async def handle_spam_result( + self, + agent_response: AgentExecutorResponse, + ctx: WorkflowContext[Never, str], + ) -> None: + """Mark email as spam and log the reason.""" + text = agent_response.agent_run_response.text + spam_result = SpamDetectionResult.model_validate_json(text) + message = f"Email marked as spam: {spam_result.reason}" + await ctx.yield_output(message) + + +class EmailSenderExecutor(Executor): + """Executor that sends email responses (non-AI activity).""" + + @handler + async def handle_email_response( + self, + agent_response: AgentExecutorResponse, + ctx: WorkflowContext[Never, str], + ) -> None: + """Send the drafted email response.""" + text = agent_response.agent_run_response.text + email_response = EmailResponse.model_validate_json(text) + message = f"Email sent: {email_response.response}" + await ctx.yield_output(message) + + +# Condition function for routing +def is_spam_detected(message: Any) -> bool: + """Check if spam was detected in the email.""" + if not isinstance(message, AgentExecutorResponse): + return False + try: + result = SpamDetectionResult.model_validate_json(message.agent_run_response.text) + return result.is_spam + except Exception: + return False + + +def _create_workflow() -> Workflow: + """Create the workflow definition.""" + client_kwargs = _build_client_kwargs() + chat_client = AzureOpenAIChatClient(**client_kwargs) + + spam_agent = chat_client.create_agent( + name=SPAM_AGENT_NAME, + instructions=SPAM_DETECTION_INSTRUCTIONS, + response_format=SpamDetectionResult, + ) + + email_agent = chat_client.create_agent( + name=EMAIL_AGENT_NAME, + instructions=EMAIL_ASSISTANT_INSTRUCTIONS, + response_format=EmailResponse, + ) + + # Executors + spam_handler = SpamHandlerExecutor(id="spam_handler") + email_sender = EmailSenderExecutor(id="email_sender") + + # Build workflow + workflow = ( + WorkflowBuilder() + .set_start_executor(spam_agent) + .add_switch_case_edge_group( + spam_agent, + [ + Case(condition=is_spam_detected, target=spam_handler), + Default(target=email_agent), + ], + ) + .add_edge(email_agent, email_sender) + .build() + ) + return workflow + + +def launch(durable: bool = True) -> AgentFunctionApp | None: + workflow: Workflow | None = None + + if durable: + # Initialize app + workflow = _create_workflow() + + + app = AgentFunctionApp(workflow=workflow) + + + return app + else: + # Launch the spam detection workflow in DevUI + from agent_framework.devui import serve + from dotenv import load_dotenv + + # Load environment variables from .env file + env_path = Path(__file__).parent / ".env" + load_dotenv(dotenv_path=env_path) + + logger.info("Starting Multi-Agent Spam Detection Workflow") + logger.info("Available at: http://localhost:8094") + logger.info("\nThis workflow demonstrates:") + logger.info("- Conditional routing based on spam detection") + logger.info("- Mixing AI agents with non-AI executors (like activity functions)") + logger.info("- Path 1 (spam): SpamDetector Agent → SpamHandler Executor") + logger.info("- Path 2 (legitimate): SpamDetector Agent → EmailAssistant Agent → EmailSender Executor") + + workflow = _create_workflow() + serve(entities=[workflow], port=8094, auto_open=True) + + return None + + +# Default: Azure Functions mode +# Run with `python function_app.py --maf` for pure MAF mode with DevUI +app = launch(durable=True) + + +if __name__ == "__main__": + import sys + + if "--maf" in sys.argv: + # Run in pure MAF mode with DevUI + launch(durable=False) + else: + print("Usage: python function_app.py --maf") + print(" --maf Run in pure MAF mode with DevUI (http://localhost:8096)") + print("\nFor Azure Functions mode, use: func start") diff --git a/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/host.json b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/host.json new file mode 100644 index 0000000000..292562af8e --- /dev/null +++ b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/host.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "hubName": "%TASKHUB_NAME%", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/local.settings.json.sample b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/local.settings.json.sample new file mode 100644 index 0000000000..30edea6c08 --- /dev/null +++ b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/local.settings.json.sample @@ -0,0 +1,12 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "TASKHUB_NAME": "default", + "AZURE_OPENAI_ENDPOINT": "https://.openai.azure.com/", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", + "AZURE_OPENAI_API_KEY": "" + } +} diff --git a/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/requirements.txt b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/requirements.txt new file mode 100644 index 0000000000..792ae4864e --- /dev/null +++ b/python/samples/getting_started/azure_functions/10_workflow_no_shared_state/requirements.txt @@ -0,0 +1,3 @@ +agent-framework-azurefunctions +agent-framework +azure-identity diff --git a/python/samples/getting_started/azure_functions/11_workflow_parallel/.env.template b/python/samples/getting_started/azure_functions/11_workflow_parallel/.env.template new file mode 100644 index 0000000000..1ef634f442 --- /dev/null +++ b/python/samples/getting_started/azure_functions/11_workflow_parallel/.env.template @@ -0,0 +1,14 @@ +# Azure Functions Runtime Configuration +FUNCTIONS_WORKER_RUNTIME=python +AzureWebJobsStorage=UseDevelopmentStorage=true + +# Durable Task Scheduler Configuration +# For local development with DTS emulator: Endpoint=http://localhost:8080;TaskHub=default;Authentication=None +# For Azure: Get connection string from Azure portal +DURABLE_TASK_SCHEDULER_CONNECTION_STRING=Endpoint=http://localhost:8080;TaskHub=default;Authentication=None +TASKHUB_NAME=default + +# Azure OpenAI Configuration +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com/ +AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=your-deployment-name +AZURE_OPENAI_API_KEY=your-api-key diff --git a/python/samples/getting_started/azure_functions/11_workflow_parallel/.gitignore b/python/samples/getting_started/azure_functions/11_workflow_parallel/.gitignore new file mode 100644 index 0000000000..41f350a67c --- /dev/null +++ b/python/samples/getting_started/azure_functions/11_workflow_parallel/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +local.settings.json +.env diff --git a/python/samples/getting_started/azure_functions/11_workflow_parallel/README.md b/python/samples/getting_started/azure_functions/11_workflow_parallel/README.md new file mode 100644 index 0000000000..07c48b73e6 --- /dev/null +++ b/python/samples/getting_started/azure_functions/11_workflow_parallel/README.md @@ -0,0 +1,193 @@ +# Parallel Workflow Execution Sample + +This sample demonstrates **parallel execution** of executors and agents in Azure Durable Functions workflows. + +## Overview + +This sample showcases three different parallel execution patterns: + +1. **Two Executors in Parallel** - Fan-out to multiple activities +2. **Two Agents in Parallel** - Fan-out to multiple entities +3. **Mixed Execution** - Agents and executors can run concurrently + +## Workflow Architecture + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ PARALLEL WORKFLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Pattern 1: Two Executors in Parallel (Activities) │ +│ ───────────────────────────────────────────────── │ +│ │ +│ input_router ──┬──> [word_count_processor] ────┐ │ +│ │ │ │ +│ └──> [format_analyzer_processor]┴──> [aggregator] │ +│ │ +│ Pattern 2: Two Agents in Parallel (Entities) │ +│ ───────────────────────────────────────────── │ +│ │ +│ [prepare_for_agents] ──┬──> [SentimentAgent] ──────┐ │ +│ │ │ │ +│ └──> [KeywordAgent] ────────┴──> [prepare_for_│ +│ mixed] │ +│ │ +│ Pattern 3: Mixed Agent + Executor in Parallel │ +│ ──────────────────────────────────────────────── │ +│ │ +│ [prepare_for_mixed] ──┬──> [SummaryAgent] ─────────┐ │ +│ │ │ │ +│ └──> [statistics_processor] ─┴──> [final_report│ +│ _executor] │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## How Parallel Execution Works + +### Activities (Executors) +When multiple executors are pending in the same iteration (e.g., after a fan-out edge), they are batched and executed using `task_all()`: + +```python +# In _workflow.py - activities execute in parallel +activity_tasks = [context.call_activity("ExecuteExecutor", input) for ...] +results = yield context.task_all(activity_tasks) # All run concurrently! +``` + +### Agents (Entities) +Different agents can also run in parallel when they're pending in the same iteration: + +```python +# Different agents run in parallel +agent_tasks = [agent_a.run(...), agent_b.run(...)] +responses = yield context.task_all(agent_tasks) # Both agents run concurrently! +``` + +**Note:** Multiple messages to the *same* agent are processed sequentially to maintain conversation coherence. + +## Components + +| Component | Type | Description | +|-----------|------|-------------| +| `input_router` | Executor | Routes input JSON to parallel processors | +| `word_count_processor` | Executor | Counts words and characters | +| `format_analyzer_processor` | Executor | Analyzes document format | +| `aggregator` | Executor | Combines results from parallel processors | +| `prepare_for_agents` | Executor | Prepares content for agent analysis | +| `SentimentAnalysisAgent` | AI Agent | Analyzes text sentiment | +| `KeywordExtractionAgent` | AI Agent | Extracts keywords and categories | +| `prepare_for_mixed` | Executor | Prepares content for mixed parallel execution | +| `SummaryAgent` | AI Agent | Summarizes the document | +| `statistics_processor` | Executor | Computes document statistics | +| `FinalReportExecutor` | Executor | Compiles final report from all analyses | + +## Prerequisites + +1. **Azure OpenAI** - Endpoint and deployment configured +2. **DTS Emulator** - For durable task scheduling (recommended) +3. **Azurite** - For Azure Functions internal storage + +## Setup + +### Option 1: DevUI Mode (Local Development - No Durable Functions) + +The sample can run locally without Azure Functions infrastructure using DevUI: + +1. Copy the environment template: + ```bash + cp .env.template .env + ``` + +2. Configure `.env` with your Azure OpenAI credentials + +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +4. Run in DevUI mode (set `durable=False` in `function_app.py`): + ```bash + python function_app.py + ``` + +5. Open `http://localhost:8095` and provide input: + ```json + { + "document_id": "doc-001", + "content": "Your document text here..." + } + ``` + +### Option 2: Durable Functions Mode (Full Azure Functions) + +1. Copy configuration files: + ```bash + cp .env.template .env + cp local.settings.json.sample local.settings.json + ``` + +2. Configure `local.settings.json` with your Azure OpenAI credentials + +3. Install dependencies: + ```bash + pip install -r requirements.txt + ``` + +4. Start DTS Emulator: + ```bash + docker run -d --name dts-emulator -p 8080:8080 -p 8082:8082 mcr.microsoft.com/dts/dts-emulator:latest + ``` + +5. Start Azurite (or use VS Code extension): + ```bash + azurite --silent + ``` + +6. Run the function app (ensure `durable=True` in `function_app.py`): + ```bash + func start + ``` + +## Testing + +Use the `demo.http` file with REST Client extension or curl: + +### Analyze a Document +```bash +curl -X POST http://localhost:7071/api/workflow/run \ + -H "Content-Type: application/json" \ + -d '{ + "document_id": "doc-001", + "content": "The quarterly earnings report shows strong growth in cloud services. Revenue increased by 25%." + }' +``` + +### Check Status +```bash +curl http://localhost:7071/api/workflow/status/{instanceId} +``` + +## Observing Parallel Execution + +Open the DTS Dashboard at `http://localhost:8082` to observe: + +1. **Activity Execution Timeline** - You'll see `word_count_processor` and `format_analyzer_processor` starting at approximately the same time +2. **Agent Execution Timeline** - `SentimentAnalysisAgent` and `KeywordExtractionAgent` also start concurrently +3. **Sequential vs Parallel** - Compare with non-parallel samples to see the time savings + +## Expected Output + +```json +{ + "output": [ + "=== Document Analysis Report ===\n\n--- SentimentAnalysisAgent ---\n{\"sentiment\": \"positive\", \"confidence\": 0.85, \"explanation\": \"...\"}\n\n--- KeywordExtractionAgent ---\n{\"keywords\": [\"earnings\", \"growth\", \"cloud\"], \"categories\": [\"finance\", \"technology\"]}" + ] +} +``` + +## Key Takeaways + +1. **Parallel execution is automatic** - When multiple executors/agents are pending in the same iteration, they run in parallel +2. **Workflow graph determines parallelism** - Fan-out edges create parallel execution opportunities +3. **Mixed parallelism** - Agents and executors can run concurrently if they're in the same iteration +4. **Same-agent messages are sequential** - To maintain conversation coherence diff --git a/python/samples/getting_started/azure_functions/11_workflow_parallel/demo.http b/python/samples/getting_started/azure_functions/11_workflow_parallel/demo.http new file mode 100644 index 0000000000..a8ae96e452 --- /dev/null +++ b/python/samples/getting_started/azure_functions/11_workflow_parallel/demo.http @@ -0,0 +1,29 @@ +### Analyze a document (triggers parallel workflow) +POST http://localhost:7071/api/workflow/run +Content-Type: application/json + +{ + "document_id": "doc-001", + "content": "The quarterly earnings report shows strong growth in our cloud services division. Revenue increased by 25% compared to last year, driven by enterprise adoption. Customer satisfaction remains high at 92%. However, we face challenges in the mobile segment where competition is intense. Overall, the outlook is positive with expected continued growth in the coming quarters." +} + +### + +### Short document test +POST http://localhost:7071/api/workflow/run +Content-Type: application/json + +{ + "document_id": "doc-002", + "content": "Quick update: Project completed successfully. Team performance exceeded expectations." +} + +### + +### Check workflow status +GET http://localhost:7071/api/workflow/status/{{instanceId}} + +### + +### Health check +GET http://localhost:7071/api/health diff --git a/python/samples/getting_started/azure_functions/11_workflow_parallel/function_app.py b/python/samples/getting_started/azure_functions/11_workflow_parallel/function_app.py new file mode 100644 index 0000000000..a51a1b6a04 --- /dev/null +++ b/python/samples/getting_started/azure_functions/11_workflow_parallel/function_app.py @@ -0,0 +1,538 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Parallel Workflow Execution Sample. + +This sample demonstrates parallel execution of executors and agents in Azure Durable Functions. +It showcases three different parallel execution patterns: + +1. Two executors running concurrently (fan-out to activities) +2. Two agents running concurrently (fan-out to entities) +3. One executor and one agent running concurrently (mixed fan-out) + +The workflow simulates a document processing pipeline where: +- A document is analyzed by multiple processors in parallel +- Results are aggregated and then processed by agents +- A summary agent and statistics executor run in parallel +- Finally, combined into a single output + +Key architectural points: +- FanOut edges enable parallel execution +- Different agents run in parallel when they're in the same iteration +- Activities (executors) also run in parallel when pending together +- Mixed agent/executor fan-outs execute concurrently +""" + +import json +import logging +import os +from dataclasses import dataclass +from typing import Any + +from agent_framework import ( + AgentExecutorResponse, + Executor, + Workflow, + WorkflowBuilder, + WorkflowContext, + executor, + handler, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential +from pydantic import BaseModel +from typing_extensions import Never + +from agent_framework_azurefunctions import AgentFunctionApp + +logger = logging.getLogger(__name__) + +AZURE_OPENAI_ENDPOINT_ENV = "AZURE_OPENAI_ENDPOINT" +AZURE_OPENAI_DEPLOYMENT_ENV = "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" +AZURE_OPENAI_API_KEY_ENV = "AZURE_OPENAI_API_KEY" + +# Agent names +SENTIMENT_AGENT_NAME = "SentimentAnalysisAgent" +KEYWORD_AGENT_NAME = "KeywordExtractionAgent" +SUMMARY_AGENT_NAME = "SummaryAgent" +RECOMMENDATION_AGENT_NAME = "RecommendationAgent" + + +# ============================================================================ +# Pydantic Models for structured outputs +# ============================================================================ + + +class SentimentResult(BaseModel): + """Result from sentiment analysis.""" + sentiment: str # positive, negative, neutral + confidence: float + explanation: str + + +class KeywordResult(BaseModel): + """Result from keyword extraction.""" + keywords: list[str] + categories: list[str] + + +class SummaryResult(BaseModel): + """Result from summarization.""" + summary: str + key_points: list[str] + + +class RecommendationResult(BaseModel): + """Result from recommendation engine.""" + recommendations: list[str] + priority: str + + +@dataclass +class DocumentInput: + """Input document to be processed.""" + document_id: str + content: str + + +@dataclass +class ProcessorResult: + """Result from a document processor (executor).""" + processor_name: str + document_id: str + content: str + word_count: int + char_count: int + has_numbers: bool + + +@dataclass +class AggregatedResults: + """Aggregated results from parallel processors.""" + document_id: str + content: str + processor_results: list[ProcessorResult] + + +@dataclass +class AgentAnalysis: + """Analysis result from an agent.""" + agent_name: str + result: str + + +@dataclass +class FinalReport: + """Final combined report.""" + document_id: str + analyses: list[AgentAnalysis] + + +# ============================================================================ +# Executor Definitions (Activities - run in parallel when pending together) +# ============================================================================ + + +@executor(id="input_router") +async def input_router( + doc: str, + ctx: WorkflowContext[DocumentInput] +) -> None: + """Route input document to parallel processors. + + Accepts a JSON string from the HTTP request and converts to DocumentInput. + """ + # Parse the JSON string input + data = json.loads(doc) if isinstance(doc, str) else doc + document = DocumentInput( + document_id=data.get("document_id", "unknown"), + content=data.get("content", ""), + ) + logger.info("[input_router] Routing document: %s", document.document_id) + await ctx.send_message(document) + + +@executor(id="word_count_processor") +async def word_count_processor( + doc: DocumentInput, + ctx: WorkflowContext[ProcessorResult] +) -> None: + """Process document and count words - runs as an activity.""" + logger.info("[word_count_processor] Processing document: %s", doc.document_id) + + word_count = len(doc.content.split()) + char_count = len(doc.content) + has_numbers = any(c.isdigit() for c in doc.content) + + result = ProcessorResult( + processor_name="word_count", + document_id=doc.document_id, + content=doc.content, + word_count=word_count, + char_count=char_count, + has_numbers=has_numbers, + ) + + await ctx.send_message(result) + + +@executor(id="format_analyzer_processor") +async def format_analyzer_processor( + doc: DocumentInput, + ctx: WorkflowContext[ProcessorResult] +) -> None: + """Analyze document format - runs as an activity in parallel with word_count.""" + logger.info("[format_analyzer_processor] Processing document: %s", doc.document_id) + + # Simple format analysis + lines = doc.content.split('\n') + word_count = len(lines) # Using line count as "word count" for this processor + char_count = sum(len(line) for line in lines) + has_numbers = doc.content.count('.') > 0 # Check for sentences + + result = ProcessorResult( + processor_name="format_analyzer", + document_id=doc.document_id, + content=doc.content, + word_count=word_count, + char_count=char_count, + has_numbers=has_numbers, + ) + + await ctx.send_message(result) + + +@executor(id="aggregator") +async def aggregator( + results: list[ProcessorResult], + ctx: WorkflowContext[AggregatedResults] +) -> None: + """Aggregate results from parallel processors - receives fan-in input.""" + logger.info("[aggregator] Aggregating %d results", len(results)) + + # Extract document info from the first result (all have the same content) + document_id = results[0].document_id if results else "unknown" + content = results[0].content if results else "" + + aggregated = AggregatedResults( + document_id=document_id, + content=content, + processor_results=results, + ) + + await ctx.send_message(aggregated) + + +@executor(id="prepare_for_agents") +async def prepare_for_agents( + aggregated: AggregatedResults, + ctx: WorkflowContext[str] +) -> None: + """Prepare content for agent analysis - broadcasts to multiple agents.""" + logger.info("[prepare_for_agents] Preparing content for agents") + + # Send the original content to agents for analysis + await ctx.send_message(aggregated.content) + + +@executor(id="prepare_for_mixed") +async def prepare_for_mixed( + analyses: list[AgentExecutorResponse], + ctx: WorkflowContext[str] +) -> None: + """Prepare results for mixed agent+executor parallel processing. + + Combines agent analysis results into a string that can be consumed by + both the SummaryAgent and the statistics_processor in parallel. + """ + logger.info("[prepare_for_mixed] Preparing for mixed parallel pattern") + + sentiment_text = "" + keyword_text = "" + + for analysis in analyses: + executor_id = analysis.executor_id + text = analysis.agent_run_response.text if analysis.agent_run_response else "" + + if executor_id == SENTIMENT_AGENT_NAME: + sentiment_text = text + elif executor_id == KEYWORD_AGENT_NAME: + keyword_text = text + + # Combine into a string that both agent and executor can process + combined = f"Sentiment Analysis: {sentiment_text}\n\nKeyword Extraction: {keyword_text}" + await ctx.send_message(combined) + + +@executor(id="statistics_processor") +async def statistics_processor( + analysis_text: str, + ctx: WorkflowContext[ProcessorResult] +) -> None: + """Calculate statistics from the analysis - runs in parallel with SummaryAgent.""" + logger.info("[statistics_processor] Calculating statistics") + + # Calculate some statistics from the combined analysis + word_count = len(analysis_text.split()) + char_count = len(analysis_text) + has_numbers = any(c.isdigit() for c in analysis_text) + + result = ProcessorResult( + processor_name="statistics", + document_id="analysis", + content=analysis_text, + word_count=word_count, + char_count=char_count, + has_numbers=has_numbers, + ) + await ctx.send_message(result) + + +class FinalReportExecutor(Executor): + """Executor that compiles the final report from agent analyses.""" + + @handler + async def compile_report( + self, + analyses: list[AgentExecutorResponse | ProcessorResult], + ctx: WorkflowContext[Never, str], + ) -> None: + """Compile final report from mixed agent + processor results.""" + logger.info("[final_report] Compiling report from %d analyses", len(analyses)) + + report_parts = ["=== Document Analysis Report ===\n"] + + for analysis in analyses: + if isinstance(analysis, AgentExecutorResponse): + agent_name = analysis.executor_id + text = analysis.agent_run_response.text if analysis.agent_run_response else "No response" + elif isinstance(analysis, ProcessorResult): + agent_name = f"Processor: {analysis.processor_name}" + text = f"Words: {analysis.word_count}, Chars: {analysis.char_count}" + else: + continue + + report_parts.append(f"\n--- {agent_name} ---") + report_parts.append(text) + + final_report = "\n".join(report_parts) + await ctx.yield_output(final_report) + + +class MixedResultCollector(Executor): + """Collector for mixed agent/executor results.""" + + @handler + async def collect_mixed_results( + self, + results: list[Any], + ctx: WorkflowContext[Never, str], + ) -> None: + """Collect and format results from mixed parallel execution.""" + logger.info("[mixed_collector] Collecting %d mixed results", len(results)) + + output_parts = ["=== Mixed Parallel Execution Results ===\n"] + + for result in results: + if isinstance(result, AgentExecutorResponse): + output_parts.append(f"[Agent: {result.executor_id}]") + output_parts.append(result.agent_run_response.text if result.agent_run_response else "No response") + elif isinstance(result, ProcessorResult): + output_parts.append(f"[Processor: {result.processor_name}]") + output_parts.append(f" Words: {result.word_count}, Chars: {result.char_count}") + + await ctx.yield_output("\n".join(output_parts)) + + +# ============================================================================ +# Workflow Construction +# ============================================================================ + + +def _build_client_kwargs() -> dict[str, Any]: + """Build Azure OpenAI client kwargs from environment variables.""" + endpoint = os.getenv(AZURE_OPENAI_ENDPOINT_ENV) + if not endpoint: + raise RuntimeError(f"{AZURE_OPENAI_ENDPOINT_ENV} environment variable is required.") + + deployment = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) + if not deployment: + raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") + + client_kwargs: dict[str, Any] = { + "endpoint": endpoint, + "deployment_name": deployment, + } + + api_key = os.getenv(AZURE_OPENAI_API_KEY_ENV) + if api_key: + client_kwargs["api_key"] = api_key + else: + client_kwargs["credential"] = AzureCliCredential() + + return client_kwargs + + +def _create_workflow() -> Workflow: + """Create the parallel workflow definition. + + Workflow structure demonstrating three parallel patterns: + + Pattern 1: Two Executors in Parallel (Fan-out/Fan-in to activities) + ──────────────────────────────────────────────────────────────────── + ┌─> word_count_processor ─────┐ + input_router ──┤ ├──> aggregator + └─> format_analyzer_processor ─┘ + + Pattern 2: Two Agents in Parallel (Fan-out to entities) + ──────────────────────────────────────────────────────── + prepare_for_agents ─┬─> SentimentAgent ──┐ + └─> KeywordAgent ────┤ + └──> prepare_for_mixed + + Pattern 3: Mixed Agent + Executor in Parallel + ────────────────────────────────────────────── + prepare_for_mixed ─┬─> SummaryAgent ────────┐ + └─> statistics_processor ─┤ + └──> final_report + """ + client_kwargs = _build_client_kwargs() + chat_client = AzureOpenAIChatClient(**client_kwargs) + + # Create agents for parallel analysis + sentiment_agent = chat_client.create_agent( + name=SENTIMENT_AGENT_NAME, + instructions=( + "You are a sentiment analysis expert. Analyze the sentiment of the given text. " + "Return JSON with fields: sentiment (positive/negative/neutral), " + "confidence (0.0-1.0), and explanation (brief reasoning)." + ), + response_format=SentimentResult, + ) + + keyword_agent = chat_client.create_agent( + name=KEYWORD_AGENT_NAME, + instructions=( + "You are a keyword extraction expert. Extract important keywords and categories " + "from the given text. Return JSON with fields: keywords (list of strings), " + "and categories (list of topic categories)." + ), + response_format=KeywordResult, + ) + + # Create summary agent for Pattern 3 (mixed parallel) + summary_agent = chat_client.create_agent( + name=SUMMARY_AGENT_NAME, + instructions=( + "You are a summarization expert. Given analysis results (sentiment and keywords), " + "provide a concise summary. Return JSON with fields: summary (brief text), " + "and key_points (list of main takeaways)." + ), + response_format=SummaryResult, + ) + + # Create executor instances + final_report_executor = FinalReportExecutor(id="final_report") + + # Build workflow with parallel patterns + workflow = ( + WorkflowBuilder() + # Start: Route input to parallel processors + .set_start_executor(input_router) + + # Pattern 1: Fan-out to two executors (run in parallel) + .add_fan_out_edges( + source=input_router, + targets=[word_count_processor, format_analyzer_processor], + ) + + # Fan-in: Both processors send results to aggregator + .add_fan_in_edges( + sources=[word_count_processor, format_analyzer_processor], + target=aggregator, + ) + + # Prepare content for agent analysis + .add_edge(aggregator, prepare_for_agents) + + # Pattern 2: Fan-out to two agents (run in parallel) + .add_fan_out_edges( + source=prepare_for_agents, + targets=[sentiment_agent, keyword_agent], + ) + + # Fan-in: Collect agent results into prepare_for_mixed + .add_fan_in_edges( + sources=[sentiment_agent, keyword_agent], + target=prepare_for_mixed, + ) + + # Pattern 3: Fan-out to one agent + one executor (mixed parallel) + .add_fan_out_edges( + source=prepare_for_mixed, + targets=[summary_agent, statistics_processor], + ) + + # Final fan-in: Collect mixed results + .add_fan_in_edges( + sources=[summary_agent, statistics_processor], + target=final_report_executor, + ) + + .build() + ) + + return workflow + + +# ============================================================================ +# Application Entry Point +# ============================================================================ + + +def launch(durable: bool = True) -> AgentFunctionApp | None: + """Launch the function app or DevUI.""" + workflow: Workflow | None = None + + if durable: + workflow = _create_workflow() + app = AgentFunctionApp( + workflow=workflow, + enable_health_check=True, + ) + return app + else: + from pathlib import Path + from agent_framework.devui import serve + from dotenv import load_dotenv + + env_path = Path(__file__).parent / ".env" + load_dotenv(dotenv_path=env_path) + + logger.info("Starting Parallel Workflow Sample") + logger.info("Available at: http://localhost:8095") + logger.info("\nThis workflow demonstrates:") + logger.info("- Pattern 1: Two executors running in parallel") + logger.info("- Pattern 2: Two agents running in parallel") + logger.info("- Pattern 3: Mixed agent + executor running in parallel") + logger.info("- Fan-in aggregation of parallel results") + + workflow = _create_workflow() + serve(entities=[workflow], port=8095, auto_open=True) + + return None + + +# Default: Azure Functions mode +# Run with `python function_app.py --maf` for pure MAF mode with DevUI +app = launch(durable=True) + + +if __name__ == "__main__": + import sys + + if "--maf" in sys.argv: + # Run in pure MAF mode with DevUI + launch(durable=False) + else: + print("Usage: python function_app.py --maf") + print(" --maf Run in pure MAF mode with DevUI (http://localhost:8095)") + print("\nFor Azure Functions mode, use: func start") diff --git a/python/samples/getting_started/azure_functions/11_workflow_parallel/host.json b/python/samples/getting_started/azure_functions/11_workflow_parallel/host.json new file mode 100644 index 0000000000..292562af8e --- /dev/null +++ b/python/samples/getting_started/azure_functions/11_workflow_parallel/host.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "hubName": "%TASKHUB_NAME%", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/python/samples/getting_started/azure_functions/11_workflow_parallel/local.settings.json.sample b/python/samples/getting_started/azure_functions/11_workflow_parallel/local.settings.json.sample new file mode 100644 index 0000000000..30edea6c08 --- /dev/null +++ b/python/samples/getting_started/azure_functions/11_workflow_parallel/local.settings.json.sample @@ -0,0 +1,12 @@ +{ + "IsEncrypted": false, + "Values": { + "FUNCTIONS_WORKER_RUNTIME": "python", + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "TASKHUB_NAME": "default", + "AZURE_OPENAI_ENDPOINT": "https://.openai.azure.com/", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "", + "AZURE_OPENAI_API_KEY": "" + } +} diff --git a/python/samples/getting_started/azure_functions/11_workflow_parallel/requirements.txt b/python/samples/getting_started/azure_functions/11_workflow_parallel/requirements.txt new file mode 100644 index 0000000000..792ae4864e --- /dev/null +++ b/python/samples/getting_started/azure_functions/11_workflow_parallel/requirements.txt @@ -0,0 +1,3 @@ +agent-framework-azurefunctions +agent-framework +azure-identity diff --git a/python/samples/getting_started/azure_functions/12_workflow_hitl/.gitignore b/python/samples/getting_started/azure_functions/12_workflow_hitl/.gitignore new file mode 100644 index 0000000000..7097fe0170 --- /dev/null +++ b/python/samples/getting_started/azure_functions/12_workflow_hitl/.gitignore @@ -0,0 +1,5 @@ +# Local settings - copy from local.settings.json.sample and fill in your values +local.settings.json +__pycache__/ +*.pyc +.venv/ diff --git a/python/samples/getting_started/azure_functions/12_workflow_hitl/README.md b/python/samples/getting_started/azure_functions/12_workflow_hitl/README.md new file mode 100644 index 0000000000..2bb84f16dc --- /dev/null +++ b/python/samples/getting_started/azure_functions/12_workflow_hitl/README.md @@ -0,0 +1,141 @@ +# 12. Workflow with Human-in-the-Loop (HITL) + +This sample demonstrates how to integrate human approval into a MAF workflow running on Azure Durable Functions using the MAF `request_info` and `@response_handler` pattern. + +## Overview + +The sample implements a content moderation pipeline: + +1. **User starts workflow** with content for publication via HTTP endpoint +2. **AI Agent analyzes** the content for policy compliance +3. **Workflow pauses** and requests human reviewer approval +4. **Human responds** via HTTP endpoint with approval/rejection +5. **Workflow resumes** and publishes or rejects the content + +## Key Concepts + +### MAF HITL Pattern + +This sample uses MAF's built-in human-in-the-loop pattern: + +```python +# In an executor, request human input +await ctx.request_info( + request_data=HumanApprovalRequest(...), + response_type=HumanApprovalResponse, +) + +# Handle the response in a separate method +@response_handler +async def handle_approval_response( + self, + original_request: HumanApprovalRequest, + response: HumanApprovalResponse, + ctx: WorkflowContext, +) -> None: + # Process the human's decision + ... +``` + +### Automatic HITL Endpoints + +`AgentFunctionApp` automatically provides all the HTTP endpoints needed for HITL: + +| Endpoint | Description | +|----------|-------------| +| `POST /api/workflow/run` | Start the workflow | +| `GET /api/workflow/status/{instanceId}` | Check status and pending HITL requests | +| `POST /api/workflow/respond/{instanceId}/{requestId}` | Send human response | +| `GET /api/health` | Health check | + +### Durable Functions Integration + +When running on Durable Functions, the HITL pattern maps to: + +| MAF Concept | Durable Functions | +|-------------|-------------------| +| `ctx.request_info()` | Workflow pauses, custom status updated | +| `RequestInfoEvent` | Exposed via status endpoint | +| HTTP response | `client.raise_event(instance_id, request_id, data)` | +| `@response_handler` | Workflow resumes, handler invoked | + +## Workflow Architecture + +``` +┌─────────────────┐ ┌──────────────────────┐ ┌────────────────────────┐ +│ Input Router │ ──► │ Content Analyzer │ ──► │ Content Analyzer │ +│ Executor │ │ Agent (AI) │ │ Executor (Parse JSON) │ +└─────────────────┘ └──────────────────────┘ └────────────────────────┘ + │ + ▼ +┌─────────────────┐ ┌──────────────────────┐ +│ Publish │ ◄── │ Human Review │ ◄── HITL PAUSE +│ Executor │ │ Executor │ (wait for external event) +└─────────────────┘ └──────────────────────┘ +``` + +## Prerequisites + +1. **Azure OpenAI** - Access to Azure OpenAI with a deployed chat model +2. **Durable Task Scheduler** - Local emulator or Azure deployment +3. **Azurite** - Local Azure Storage emulator +4. **Azure CLI** - For authentication (`az login`) + +## Setup + +1. Copy the sample settings file: + ```bash + cp local.settings.json.sample local.settings.json + ``` + +2. Update `local.settings.json` with your Azure OpenAI credentials: + ```json + { + "Values": { + "AZURE_OPENAI_ENDPOINT": "https://your-resource.openai.azure.com/", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "gpt-4o" + } + } + ``` + +3. Start the local emulators: + ```bash + # Terminal 1: Start Azurite + azurite --silent --location . + + # Terminal 2: Start Durable Task Scheduler (if using local emulator) + # Follow Durable Task Scheduler setup instructions + ``` + +4. Start the Function App: + ```bash + func start + ``` + +## Running in Pure MAF Mode + +You can also run this sample in pure MAF mode (without Durable Functions) using the DevUI: + +```bash +python function_app.py --maf +``` + +This launches the DevUI at http://localhost:8096 where you can interact with the workflow directly. This is useful for: +- Local development and debugging +- Testing the HITL pattern without Durable Functions infrastructure +- Comparing behavior between MAF and Durable modes + +## Testing + +Use the `demo.http` file with the VS Code REST Client extension: + +1. **Start workflow** - `POST /api/workflow/run` with content payload +2. **Check status** - `GET /api/workflow/status/{instanceId}` to see pending HITL requests +3. **Send response** - `POST /api/workflow/respond/{instanceId}/{requestId}` with approval +4. **Check result** - `GET /api/workflow/status/{instanceId}` to see final output + +## Related Samples + +- [07_single_agent_orchestration_hitl](../07_single_agent_orchestration_hitl/) - HITL at orchestrator level (not using MAF pattern) +- [09_workflow_shared_state](../09_workflow_shared_state/) - Workflow with shared state +- [guessing_game_with_human_input](../../workflows/human-in-the-loop/guessing_game_with_human_input.py) - MAF HITL pattern (non-durable) diff --git a/python/samples/getting_started/azure_functions/12_workflow_hitl/demo.http b/python/samples/getting_started/azure_functions/12_workflow_hitl/demo.http new file mode 100644 index 0000000000..b59ae8b61c --- /dev/null +++ b/python/samples/getting_started/azure_functions/12_workflow_hitl/demo.http @@ -0,0 +1,123 @@ +### ============================================================================ +### Workflow HITL Sample - Content Moderation with Human Approval +### ============================================================================ +### This sample demonstrates MAF workflows with human-in-the-loop using the +### request_info / @response_handler pattern on Azure Durable Functions. +### +### The AgentFunctionApp automatically provides all HITL endpoints. +### +### Prerequisites: +### 1. Start Azurite: azurite --silent --location . +### 2. Start Durable Task Scheduler emulator +### 3. Configure local.settings.json with Azure OpenAI credentials +### 4. Run: func start +### ============================================================================ + + +### ============================================================================ +### 1. Start the Workflow with Content for Moderation +### ============================================================================ +### This starts the workflow. The AI will analyze the content, then the workflow +### will pause waiting for human approval. + +POST http://localhost:7071/api/workflow/run +Content-Type: application/json + +{ + "content_id": "article-001", + "title": "Introduction to AI in Healthcare", + "body": "Artificial intelligence is revolutionizing healthcare by enabling faster diagnosis, personalized treatment plans, and improved patient outcomes. Machine learning algorithms can analyze medical images with remarkable accuracy, often detecting issues that human radiologists might miss.", + "author": "Dr. Jane Smith" +} + + +### ============================================================================ +### 2. Start Workflow with Potentially Problematic Content +### ============================================================================ +### This content should trigger higher risk assessment from the AI analyzer. + +POST http://localhost:7071/api/workflow/run +Content-Type: application/json + +{ + "content_id": "article-002", + "title": "Get Rich Quick Scheme", + "body": "Click here NOW to make $10,000 overnight! This SECRET method is GUARANTEED to work! Limited time offer - act NOW before it's too late! Send your bank details immediately!", + "author": "Definitely Not Spam" +} + + +### ============================================================================ +### 3. Check Workflow Status +### ============================================================================ +### Replace INSTANCE_ID with the value returned from the run call. +### The status will show pending HITL requests if waiting for human approval. + +@instanceId = 3130c486c9374e4e87125cbd9a238dfc + +GET http://localhost:7071/api/workflow/status/{{instanceId}} + + +### ============================================================================ +### 4. Send Human Approval +### ============================================================================ +### Approve the content for publication. +### Replace INSTANCE_ID and REQUEST_ID with values from the status response. + +@requestId = 1682e5f8-0917-4b68-aa04-d4688cfa2e69 + +POST http://localhost:7071/api/workflow/respond/{{instanceId}}/{{requestId}} +Content-Type: application/json + +{ + "approved": true, + "reviewer_notes": "Content is appropriate and well-written. Approved for publication." +} + + +### ============================================================================ +### 5. Send Human Rejection +### ============================================================================ +### Reject the content with feedback. + +POST http://localhost:7071/api/workflow/respond/{{instanceId}}/{{requestId}} +Content-Type: application/json + +{ + "approved": false, + "reviewer_notes": "Content appears to be spam. Contains multiple spam indicators including urgency language, promises of easy money, and requests for personal information." +} + + +### ============================================================================ +### Example Workflow - Complete Happy Path +### ============================================================================ +### +### Step 1: Start workflow with content +### POST http://localhost:7071/api/workflow/run +### -> Returns instanceId: "abc123..." +### +### Step 2: Check status (workflow is waiting for human input) +### GET http://localhost:7071/api/workflow/status/abc123 +### -> Returns pendingHumanInputRequests with requestId: "req-456..." +### +### Step 3: Approve content +### POST http://localhost:7071/api/workflow/respond/abc123/req-456 +### { +### "approved": true, +### "reviewer_notes": "Looks good!" +### } +### -> Returns success +### +### Step 4: Check final status +### GET http://localhost:7071/api/workflow/status/abc123 +### -> Returns runtimeStatus: "Completed", output: "✅ Content approved..." +### +### ============================================================================ + + +### ============================================================================ +### Health Check +### ============================================================================ + +GET http://localhost:7071/api/health diff --git a/python/samples/getting_started/azure_functions/12_workflow_hitl/function_app.py b/python/samples/getting_started/azure_functions/12_workflow_hitl/function_app.py new file mode 100644 index 0000000000..bb36832b17 --- /dev/null +++ b/python/samples/getting_started/azure_functions/12_workflow_hitl/function_app.py @@ -0,0 +1,468 @@ +# Copyright (c) Microsoft. All rights reserved. +"""Workflow with Human-in-the-Loop (HITL) using MAF request_info Pattern. + +This sample demonstrates how to integrate human approval into a MAF workflow +running on Azure Durable Functions. It uses the MAF `request_info` and +`@response_handler` pattern for structured HITL interactions. + +The workflow simulates a content moderation pipeline: +1. User submits content for publication +2. An AI agent analyzes the content for policy compliance +3. A human reviewer is prompted to approve/reject the content +4. Based on approval, content is either published or rejected + +Key architectural points: +- Uses MAF's `ctx.request_info()` to pause workflow and request human input +- Uses `@response_handler` decorator to handle the human's response +- AgentFunctionApp automatically provides HITL endpoints for status and response +- Durable Functions provides durability while waiting for human input + +Prerequisites: +- Azure OpenAI configured with required environment variables +- Durable Task Scheduler connection string +- Authentication via Azure CLI (az login) +""" + +import json +import logging +import os +from dataclasses import dataclass +from typing import Any + +from agent_framework import ( + AgentExecutorRequest, + AgentExecutorResponse, + ChatMessage, + Executor, + Role, + Workflow, + WorkflowBuilder, + WorkflowContext, + handler, + response_handler, +) +from agent_framework.azure import AzureOpenAIChatClient +from azure.identity import AzureCliCredential +from pydantic import BaseModel +from typing_extensions import Never + +from agent_framework_azurefunctions import AgentFunctionApp + +logger = logging.getLogger(__name__) + +# Environment variable names +AZURE_OPENAI_ENDPOINT_ENV = "AZURE_OPENAI_ENDPOINT" +AZURE_OPENAI_DEPLOYMENT_ENV = "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME" +AZURE_OPENAI_API_KEY_ENV = "AZURE_OPENAI_API_KEY" + +# Agent names +CONTENT_ANALYZER_AGENT_NAME = "ContentAnalyzerAgent" + + +# ============================================================================ +# Data Models +# ============================================================================ + + +class ContentAnalysisResult(BaseModel): + """Structured output from the content analysis agent.""" + + is_appropriate: bool + risk_level: str # low, medium, high + concerns: list[str] + recommendation: str + + +@dataclass +class ContentSubmission: + """Content submitted for moderation.""" + + content_id: str + title: str + body: str + author: str + + +@dataclass +class HumanApprovalRequest: + """Request sent to human reviewer for approval. + + This is the payload passed to ctx.request_info() and will be + exposed via the orchestration status for external systems to retrieve. + """ + + content_id: str + title: str + body: str + author: str + ai_analysis: ContentAnalysisResult + prompt: str + + +class HumanApprovalResponse(BaseModel): + """Response from human reviewer. + + This is what the external system must send back via the HITL response endpoint. + """ + + approved: bool + reviewer_notes: str = "" + + +@dataclass +class ModerationResult: + """Final result of the moderation workflow.""" + + content_id: str + status: str # "approved", "rejected" + ai_analysis: ContentAnalysisResult | None + reviewer_notes: str + + +# ============================================================================ +# Agent Instructions +# ============================================================================ + +CONTENT_ANALYZER_INSTRUCTIONS = """You are a content moderation assistant that analyzes user-submitted content +for policy compliance. Evaluate the content for: + +1. Appropriateness - Is the content suitable for a general audience? +2. Risk level - Rate as 'low', 'medium', or 'high' based on potential issues +3. Concerns - List any specific issues found (empty list if none) +4. Recommendation - Provide a brief recommendation for human reviewers + +Return a JSON response with: +- is_appropriate: boolean +- risk_level: string ('low', 'medium', 'high') +- concerns: list of strings +- recommendation: string + +Be thorough but fair in your analysis.""" + + +# ============================================================================ +# Executors +# ============================================================================ + + +@dataclass +class AnalysisWithSubmission: + """Combines the AI analysis with the original submission for downstream processing.""" + + submission: ContentSubmission + analysis: ContentAnalysisResult + + +class ContentAnalyzerExecutor(Executor): + """Parses the AI agent's response and prepares for human review.""" + + def __init__(self): + super().__init__(id="content_analyzer_executor") + + @handler + async def handle_analysis( + self, + response: AgentExecutorResponse, + ctx: WorkflowContext[AnalysisWithSubmission], + ) -> None: + """Parse the AI analysis and forward with submission context.""" + analysis = ContentAnalysisResult.model_validate_json(response.agent_run_response.text) + + # Retrieve the original submission from shared state + submission: ContentSubmission = await ctx.get_shared_state("current_submission") + + await ctx.send_message(AnalysisWithSubmission(submission=submission, analysis=analysis)) + + +class HumanReviewExecutor(Executor): + """Requests human approval using MAF's request_info pattern. + + This executor demonstrates the core HITL pattern: + 1. Receives the AI analysis result + 2. Calls ctx.request_info() to pause and request human input + 3. The @response_handler method processes the human's response + """ + + def __init__(self): + super().__init__(id="human_review_executor") + + @handler + async def request_review( + self, + data: AnalysisWithSubmission, + ctx: WorkflowContext, + ) -> None: + """Request human review for the content. + + This method: + 1. Constructs the approval request with all context + 2. Calls request_info to pause the workflow + 3. The workflow will resume when a response is provided via the HITL endpoint + """ + submission = data.submission + analysis = data.analysis + + # Construct the human-readable prompt + prompt = ( + f"Please review the following content for publication:\n\n" + f"Title: {submission.title}\n" + f"Author: {submission.author}\n" + f"Content: {submission.body}\n\n" + f"AI Analysis:\n" + f"- Appropriate: {analysis.is_appropriate}\n" + f"- Risk Level: {analysis.risk_level}\n" + f"- Concerns: {', '.join(analysis.concerns) if analysis.concerns else 'None'}\n" + f"- Recommendation: {analysis.recommendation}\n\n" + f"Please approve or reject this content." + ) + + approval_request = HumanApprovalRequest( + content_id=submission.content_id, + title=submission.title, + body=submission.body, + author=submission.author, + ai_analysis=analysis, + prompt=prompt, + ) + + # Store analysis in shared state for the response handler + await ctx.set_shared_state("pending_analysis", data) + + # Request human input - workflow will pause here + # The response_type specifies what we expect back + await ctx.request_info( + request_data=approval_request, + response_type=HumanApprovalResponse, + ) + + @response_handler + async def handle_approval_response( + self, + original_request: HumanApprovalRequest, + response: HumanApprovalResponse, + ctx: WorkflowContext[ModerationResult], + ) -> None: + """Process the human reviewer's decision. + + This method is called automatically when a response to request_info is received. + The original_request contains the HumanApprovalRequest we sent. + The response contains the HumanApprovalResponse from the reviewer. + """ + logger.info( + "Human review received for content %s: approved=%s, notes=%s", + original_request.content_id, + response.approved, + response.reviewer_notes, + ) + + # Create the final moderation result + status = "approved" if response.approved else "rejected" + result = ModerationResult( + content_id=original_request.content_id, + status=status, + ai_analysis=original_request.ai_analysis, + reviewer_notes=response.reviewer_notes, + ) + + await ctx.send_message(result) + + +class PublishExecutor(Executor): + """Handles the final publication or rejection of content.""" + + def __init__(self): + super().__init__(id="publish_executor") + + @handler + async def handle_result( + self, + result: ModerationResult, + ctx: WorkflowContext[Never, str], + ) -> None: + """Finalize the moderation and yield output.""" + if result.status == "approved": + message = ( + f"✅ Content '{result.content_id}' has been APPROVED and published.\n" + f"Reviewer notes: {result.reviewer_notes or 'None'}" + ) + else: + message = ( + f"❌ Content '{result.content_id}' has been REJECTED.\n" + f"Reviewer notes: {result.reviewer_notes or 'None'}" + ) + + logger.info(message) + await ctx.yield_output(message) + + +# ============================================================================ +# Input Router Executor +# ============================================================================ + + +def _build_client_kwargs() -> dict[str, Any]: + """Build Azure OpenAI client configuration from environment variables.""" + endpoint = os.getenv(AZURE_OPENAI_ENDPOINT_ENV) + if not endpoint: + raise RuntimeError(f"{AZURE_OPENAI_ENDPOINT_ENV} environment variable is required.") + + deployment = os.getenv(AZURE_OPENAI_DEPLOYMENT_ENV) + if not deployment: + raise RuntimeError(f"{AZURE_OPENAI_DEPLOYMENT_ENV} environment variable is required.") + + client_kwargs: dict[str, Any] = { + "endpoint": endpoint, + "deployment_name": deployment, + } + + api_key = os.getenv(AZURE_OPENAI_API_KEY_ENV) + if api_key: + client_kwargs["api_key"] = api_key + else: + client_kwargs["credential"] = AzureCliCredential() + + return client_kwargs + + +class InputRouterExecutor(Executor): + """Routes incoming content submission to the analysis agent.""" + + def __init__(self): + super().__init__(id="input_router") + + @handler + async def route_input( + self, + input_json: str, + ctx: WorkflowContext[AgentExecutorRequest], + ) -> None: + """Parse input and create agent request.""" + data = json.loads(input_json) if isinstance(input_json, str) else input_json + + submission = ContentSubmission( + content_id=data.get("content_id", "unknown"), + title=data.get("title", "Untitled"), + body=data.get("body", ""), + author=data.get("author", "Anonymous"), + ) + + # Store submission in shared state for later retrieval + await ctx.set_shared_state("current_submission", submission) + + # Create the agent request + message = ( + f"Please analyze the following content for policy compliance:\n\n" + f"Title: {submission.title}\n" + f"Author: {submission.author}\n" + f"Content:\n{submission.body}" + ) + + await ctx.send_message( + AgentExecutorRequest( + messages=[ChatMessage(Role.USER, text=message)], + should_respond=True, + ) + ) + + +# ============================================================================ +# Workflow Creation +# ============================================================================ + + +def _create_workflow() -> Workflow: + """Create the content moderation workflow with HITL.""" + client_kwargs = _build_client_kwargs() + chat_client = AzureOpenAIChatClient(**client_kwargs) + + # Create the content analysis agent + content_analyzer_agent = chat_client.create_agent( + name=CONTENT_ANALYZER_AGENT_NAME, + instructions=CONTENT_ANALYZER_INSTRUCTIONS, + response_format=ContentAnalysisResult, + ) + + # Create executors + input_router = InputRouterExecutor() + content_analyzer_executor = ContentAnalyzerExecutor() + human_review_executor = HumanReviewExecutor() + publish_executor = PublishExecutor() + + # Build the workflow graph + # Flow: + # input_router -> content_analyzer_agent -> content_analyzer_executor + # -> human_review_executor (HITL pause here) -> publish_executor + workflow = ( + WorkflowBuilder() + .set_start_executor(input_router) + .add_edge(input_router, content_analyzer_agent) + .add_edge(content_analyzer_agent, content_analyzer_executor) + .add_edge(content_analyzer_executor, human_review_executor) + .add_edge(human_review_executor, publish_executor) + .build() + ) + + return workflow + + +# ============================================================================ +# Application Entry Point +# ============================================================================ + + +def launch(durable: bool = True) -> AgentFunctionApp | None: + """Launch the function app or DevUI. + + Args: + durable: If True, returns AgentFunctionApp for Azure Functions. + If False, launches DevUI for local MAF development. + """ + if durable: + # Azure Functions mode with Durable Functions + # The app automatically provides HITL endpoints: + # - POST /api/workflow/run - Start the workflow + # - GET /api/workflow/status/{instanceId} - Check status and pending HITL requests + # - POST /api/workflow/respond/{instanceId}/{requestId} - Send HITL response + # - GET /api/health - Health check + workflow = _create_workflow() + app = AgentFunctionApp(workflow=workflow, enable_health_check=True) + return app + else: + # Pure MAF mode with DevUI for local development + from pathlib import Path + + from agent_framework.devui import serve + from dotenv import load_dotenv + + env_path = Path(__file__).parent / ".env" + load_dotenv(dotenv_path=env_path) + + logger.info("Starting Workflow HITL Sample in MAF mode") + logger.info("Available at: http://localhost:8096") + logger.info("\nThis workflow demonstrates:") + logger.info("- Human-in-the-loop using request_info / @response_handler pattern") + logger.info("- AI content analysis with structured output") + logger.info("- Human approval workflow integration") + logger.info("\nFlow: InputRouter -> ContentAnalyzer Agent -> HumanReview -> Publish") + + workflow = _create_workflow() + serve(entities=[workflow], port=8096, auto_open=True) + + return None + + +# Default: Azure Functions mode +# Run with `python function_app.py --maf` for pure MAF mode with DevUI +app = launch(durable=True) + + +if __name__ == "__main__": + import sys + + if "--maf" in sys.argv: + # Run in pure MAF mode with DevUI + launch(durable=False) + else: + print("Usage: python function_app.py --maf") + print(" --maf Run in pure MAF mode with DevUI (http://localhost:8096)") + print("\nFor Azure Functions mode, use: func start") diff --git a/python/samples/getting_started/azure_functions/12_workflow_hitl/host.json b/python/samples/getting_started/azure_functions/12_workflow_hitl/host.json new file mode 100644 index 0000000000..292562af8e --- /dev/null +++ b/python/samples/getting_started/azure_functions/12_workflow_hitl/host.json @@ -0,0 +1,16 @@ +{ + "version": "2.0", + "extensionBundle": { + "id": "Microsoft.Azure.Functions.ExtensionBundle", + "version": "[4.*, 5.0.0)" + }, + "extensions": { + "durableTask": { + "hubName": "%TASKHUB_NAME%", + "storageProvider": { + "type": "AzureManaged", + "connectionStringName": "DURABLE_TASK_SCHEDULER_CONNECTION_STRING" + } + } + } +} diff --git a/python/samples/getting_started/azure_functions/12_workflow_hitl/local.settings.json.sample b/python/samples/getting_started/azure_functions/12_workflow_hitl/local.settings.json.sample new file mode 100644 index 0000000000..69c08a3386 --- /dev/null +++ b/python/samples/getting_started/azure_functions/12_workflow_hitl/local.settings.json.sample @@ -0,0 +1,11 @@ +{ + "IsEncrypted": false, + "Values": { + "AzureWebJobsStorage": "UseDevelopmentStorage=true", + "DURABLE_TASK_SCHEDULER_CONNECTION_STRING": "Endpoint=http://localhost:8080;TaskHub=default;Authentication=None", + "TASKHUB_NAME": "default", + "FUNCTIONS_WORKER_RUNTIME": "python", + "AZURE_OPENAI_ENDPOINT": "", + "AZURE_OPENAI_CHAT_DEPLOYMENT_NAME": "" + } +} diff --git a/python/samples/getting_started/azure_functions/12_workflow_hitl/requirements.txt b/python/samples/getting_started/azure_functions/12_workflow_hitl/requirements.txt new file mode 100644 index 0000000000..85e158b8d4 --- /dev/null +++ b/python/samples/getting_started/azure_functions/12_workflow_hitl/requirements.txt @@ -0,0 +1,3 @@ +agent-framework-azurefunctions +azure-identity +agents-maf diff --git a/python/uv.lock b/python/uv.lock index 227860a479..4d4460fc10 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -883,7 +883,7 @@ wheels = [ [[package]] name = "anthropic" -version = "0.75.0" +version = "0.76.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -895,9 +895,9 @@ dependencies = [ { name = "sniffio", 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/04/1f/08e95f4b7e2d35205ae5dcbb4ae97e7d477fc521c275c02609e2931ece2d/anthropic-0.75.0.tar.gz", hash = "sha256:e8607422f4ab616db2ea5baacc215dd5f028da99ce2f022e33c7c535b29f3dfb", size = 439565, upload-time = "2025-11-24T20:41:45.28Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/be/d11abafaa15d6304826438170f7574d750218f49a106c54424a40cef4494/anthropic-0.76.0.tar.gz", hash = "sha256:e0cae6a368986d5cf6df743dfbb1b9519e6a9eee9c6c942ad8121c0b34416ffe", size = 495483, upload-time = "2026-01-13T18:41:14.908Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/1c/1cd02b7ae64302a6e06724bf80a96401d5313708651d277b1458504a1730/anthropic-0.75.0-py3-none-any.whl", hash = "sha256:ea8317271b6c15d80225a9f3c670152746e88805a7a61e14d4a374577164965b", size = 388164, upload-time = "2025-11-24T20:41:43.587Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/7b0fd9c1a738f59d3babe2b4212031c34ab7d0fda4ffef15b58a55c5bcea/anthropic-0.76.0-py3-none-any.whl", hash = "sha256:81efa3113901192af2f0fe977d3ec73fdadb1e691586306c4256cd6d5ccc331c", size = 390309, upload-time = "2026-01-13T18:41:13.483Z" }, ] [[package]] @@ -2663,7 +2663,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.3.1" +version = "1.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -2677,9 +2677,9 @@ dependencies = [ { name = "typer-slim", 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/dd/dd/1cc985c5dda36298b152f75e82a1c81f52243b78fb7e9cad637a29561ad1/huggingface_hub-1.3.1.tar.gz", hash = "sha256:e80e0cfb4a75557c51ab20d575bdea6bb6106c2f97b7c75d8490642f1efb6df5", size = 622356, upload-time = "2026-01-09T14:08:16.888Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/d6/02d1c505e1d3364230e5fa16d2b58c8f36a39c5efe8e99bc4d03d06fd0ca/huggingface_hub-1.3.2.tar.gz", hash = "sha256:15d7902e154f04174a0816d1e9594adcf15cdad57596920a5dc70fadb5d896c7", size = 624018, upload-time = "2026-01-14T13:57:39.635Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/fb/cb8fe5f71d5622427f20bcab9e06a696a5aaf21bfe7bd0a8a0c63c88abf5/huggingface_hub-1.3.1-py3-none-any.whl", hash = "sha256:efbc7f3153cb84e2bb69b62ed90985e21ecc9343d15647a419fc0ee4b85f0ac3", size = 533351, upload-time = "2026-01-09T14:08:14.519Z" }, + { url = "https://files.pythonhosted.org/packages/88/1d/acd3ef8aabb7813c6ef2f91785d855583ac5cd7c3599e5c1a1a2ed1ec2e5/huggingface_hub-1.3.2-py3-none-any.whl", hash = "sha256:b552b9562a5532102a041fa31a6966bb9de95138fc7aa578bb3703198c25d1b6", size = 534504, upload-time = "2026-01-14T13:57:37.555Z" }, ] [[package]] @@ -3042,7 +3042,7 @@ wheels = [ [[package]] name = "langfuse" -version = "3.11.2" +version = "3.12.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "backoff", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3056,87 +3056,87 @@ dependencies = [ { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "wrapt", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/71/10/6b28f3b2c008b1f48478c4f45ceb956dfcc951910f5896b3fe44c20174db/langfuse-3.11.2.tar.gz", hash = "sha256:ab5f296a8056815b7288c7f25bc308a5e79f82a8634467b25daffdde99276e09", size = 230795, upload-time = "2025-12-23T20:42:57.177Z" } +sdist = { url = "https://files.pythonhosted.org/packages/05/d2/33991342653d101715faae8f82c14eb3f0a5c2d22d8c99df9dbb8d099802/langfuse-3.12.0.tar.gz", hash = "sha256:0f75b3d21d4ef4014ebeaa8188eb0c855200412b4e4fb8cceca609a7ce465f91", size = 232651, upload-time = "2026-01-13T14:17:33.659Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/04/95407023b786ed2eef1e2cd220f5baf7b1dd70d88645af129cc1fd1da867/langfuse-3.11.2-py3-none-any.whl", hash = "sha256:84faea9f909694023cc7f0eb45696be190248c8790424f22af57ca4cd7a29f2d", size = 413786, upload-time = "2025-12-23T20:42:55.48Z" }, + { url = "https://files.pythonhosted.org/packages/c3/87/141689c2c2b352ed100de4a63f64f24b4df7f883ba2a3fc0c6733d9d0451/langfuse-3.12.0-py3-none-any.whl", hash = "sha256:644d9bbfa842eb6775b1e069e23f77ad1087f5241682966b8168bbb01f9c357e", size = 416875, upload-time = "2026-01-13T14:17:31.791Z" }, ] [[package]] name = "librt" -version = "0.7.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b7/29/47f29026ca17f35cf299290292d5f8331f5077364974b7675a353179afa2/librt-0.7.7.tar.gz", hash = "sha256:81d957b069fed1890953c3b9c3895c7689960f233eea9a1d9607f71ce7f00b2c", size = 145910, upload-time = "2026-01-01T23:52:22.87Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/84/2cfb1f3b9b60bab52e16a220c931223fc8e963d0d7bb9132bef012aafc3f/librt-0.7.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e4836c5645f40fbdc275e5670819bde5ab5f2e882290d304e3c6ddab1576a6d0", size = 54709, upload-time = "2026-01-01T23:50:48.326Z" }, - { url = "https://files.pythonhosted.org/packages/19/a1/3127b277e9d3784a8040a54e8396d9ae5c64d6684dc6db4b4089b0eedcfb/librt-0.7.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6ae8aec43117a645a31e5f60e9e3a0797492e747823b9bda6972d521b436b4e8", size = 56658, upload-time = "2026-01-01T23:50:49.74Z" }, - { url = "https://files.pythonhosted.org/packages/3a/e9/b91b093a5c42eb218120445f3fef82e0b977fa2225f4d6fc133d25cdf86a/librt-0.7.7-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:aea05f701ccd2a76b34f0daf47ca5068176ff553510b614770c90d76ac88df06", size = 161026, upload-time = "2026-01-01T23:50:50.853Z" }, - { url = "https://files.pythonhosted.org/packages/c7/cb/1ded77d5976a79d7057af4a010d577ce4f473ff280984e68f4974a3281e5/librt-0.7.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b16ccaeff0ed4355dfb76fe1ea7a5d6d03b5ad27f295f77ee0557bc20a72495", size = 169529, upload-time = "2026-01-01T23:50:52.24Z" }, - { url = "https://files.pythonhosted.org/packages/da/6e/6ca5bdaa701e15f05000ac1a4c5d1475c422d3484bd3d1ca9e8c2f5be167/librt-0.7.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c48c7e150c095d5e3cea7452347ba26094be905d6099d24f9319a8b475fcd3e0", size = 183271, upload-time = "2026-01-01T23:50:55.287Z" }, - { url = "https://files.pythonhosted.org/packages/e7/2d/55c0e38073997b4bbb5ddff25b6d1bbba8c2f76f50afe5bb9c844b702f34/librt-0.7.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4dcee2f921a8632636d1c37f1bbdb8841d15666d119aa61e5399c5268e7ce02e", size = 179039, upload-time = "2026-01-01T23:50:56.807Z" }, - { url = "https://files.pythonhosted.org/packages/33/4e/3662a41ae8bb81b226f3968426293517b271d34d4e9fd4b59fc511f1ae40/librt-0.7.7-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:14ef0f4ac3728ffd85bfc58e2f2f48fb4ef4fa871876f13a73a7381d10a9f77c", size = 173505, upload-time = "2026-01-01T23:50:58.291Z" }, - { url = "https://files.pythonhosted.org/packages/f8/5d/cf768deb8bdcbac5f8c21fcb32dd483d038d88c529fd351bbe50590b945d/librt-0.7.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e4ab69fa37f8090f2d971a5d2bc606c7401170dbdae083c393d6cbf439cb45b8", size = 193570, upload-time = "2026-01-01T23:50:59.546Z" }, - { url = "https://files.pythonhosted.org/packages/a1/ea/ee70effd13f1d651976d83a2812391f6203971740705e3c0900db75d4bce/librt-0.7.7-cp310-cp310-win32.whl", hash = "sha256:4bf3cc46d553693382d2abf5f5bd493d71bb0f50a7c0beab18aa13a5545c8900", size = 42600, upload-time = "2026-01-01T23:51:00.694Z" }, - { url = "https://files.pythonhosted.org/packages/f0/eb/dc098730f281cba76c279b71783f5de2edcba3b880c1ab84a093ef826062/librt-0.7.7-cp310-cp310-win_amd64.whl", hash = "sha256:f0c8fe5aeadd8a0e5b0598f8a6ee3533135ca50fd3f20f130f9d72baf5c6ac58", size = 48977, upload-time = "2026-01-01T23:51:01.726Z" }, - { url = "https://files.pythonhosted.org/packages/f0/56/30b5c342518005546df78841cb0820ae85a17e7d07d521c10ef367306d0d/librt-0.7.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a487b71fbf8a9edb72a8c7a456dda0184642d99cd007bc819c0b7ab93676a8ee", size = 54709, upload-time = "2026-01-01T23:51:02.774Z" }, - { url = "https://files.pythonhosted.org/packages/72/78/9f120e3920b22504d4f3835e28b55acc2cc47c9586d2e1b6ba04c3c1bf01/librt-0.7.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f4d4efb218264ecf0f8516196c9e2d1a0679d9fb3bb15df1155a35220062eba8", size = 56663, upload-time = "2026-01-01T23:51:03.838Z" }, - { url = "https://files.pythonhosted.org/packages/1c/ea/7d7a1ee7dfc1151836028eba25629afcf45b56bbc721293e41aa2e9b8934/librt-0.7.7-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b8bb331aad734b059c4b450cd0a225652f16889e286b2345af5e2c3c625c3d85", size = 161705, upload-time = "2026-01-01T23:51:04.917Z" }, - { url = "https://files.pythonhosted.org/packages/45/a5/952bc840ac8917fbcefd6bc5f51ad02b89721729814f3e2bfcc1337a76d6/librt-0.7.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:467dbd7443bda08338fc8ad701ed38cef48194017554f4c798b0a237904b3f99", size = 171029, upload-time = "2026-01-01T23:51:06.09Z" }, - { url = "https://files.pythonhosted.org/packages/fa/bf/c017ff7da82dc9192cf40d5e802a48a25d00e7639b6465cfdcee5893a22c/librt-0.7.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50d1d1ee813d2d1a3baf2873634ba506b263032418d16287c92ec1cc9c1a00cb", size = 184704, upload-time = "2026-01-01T23:51:07.549Z" }, - { url = "https://files.pythonhosted.org/packages/77/ec/72f3dd39d2cdfd6402ab10836dc9cbf854d145226062a185b419c4f1624a/librt-0.7.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c7e5070cf3ec92d98f57574da0224f8c73faf1ddd6d8afa0b8c9f6e86997bc74", size = 180719, upload-time = "2026-01-01T23:51:09.062Z" }, - { url = "https://files.pythonhosted.org/packages/78/86/06e7a1a81b246f3313bf515dd9613a1c81583e6fd7843a9f4d625c4e926d/librt-0.7.7-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:bdb9f3d865b2dafe7f9ad7f30ef563c80d0ddd2fdc8cc9b8e4f242f475e34d75", size = 174537, upload-time = "2026-01-01T23:51:10.611Z" }, - { url = "https://files.pythonhosted.org/packages/83/08/f9fb2edc9c7a76e95b2924ce81d545673f5b034e8c5dd92159d1c7dae0c6/librt-0.7.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8185c8497d45164e256376f9da5aed2bb26ff636c798c9dabe313b90e9f25b28", size = 195238, upload-time = "2026-01-01T23:51:11.762Z" }, - { url = "https://files.pythonhosted.org/packages/ba/56/ea2d2489d3ea1f47b301120e03a099e22de7b32c93df9a211e6ff4f9bf38/librt-0.7.7-cp311-cp311-win32.whl", hash = "sha256:44d63ce643f34a903f09ff7ca355aae019a3730c7afd6a3c037d569beeb5d151", size = 42939, upload-time = "2026-01-01T23:51:13.192Z" }, - { url = "https://files.pythonhosted.org/packages/58/7b/c288f417e42ba2a037f1c0753219e277b33090ed4f72f292fb6fe175db4c/librt-0.7.7-cp311-cp311-win_amd64.whl", hash = "sha256:7d13cc340b3b82134f8038a2bfe7137093693dcad8ba5773da18f95ad6b77a8a", size = 49240, upload-time = "2026-01-01T23:51:14.264Z" }, - { url = "https://files.pythonhosted.org/packages/7c/24/738eb33a6c1516fdb2dfd2a35db6e5300f7616679b573585be0409bc6890/librt-0.7.7-cp311-cp311-win_arm64.whl", hash = "sha256:983de36b5a83fe9222f4f7dcd071f9b1ac6f3f17c0af0238dadfb8229588f890", size = 42613, upload-time = "2026-01-01T23:51:15.268Z" }, - { url = "https://files.pythonhosted.org/packages/56/72/1cd9d752070011641e8aee046c851912d5f196ecd726fffa7aed2070f3e0/librt-0.7.7-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2a85a1fc4ed11ea0eb0a632459ce004a2d14afc085a50ae3463cd3dfe1ce43fc", size = 55687, upload-time = "2026-01-01T23:51:16.291Z" }, - { url = "https://files.pythonhosted.org/packages/50/aa/d5a1d4221c4fe7e76ae1459d24d6037783cb83c7645164c07d7daf1576ec/librt-0.7.7-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c87654e29a35938baead1c4559858f346f4a2a7588574a14d784f300ffba0efd", size = 57136, upload-time = "2026-01-01T23:51:17.363Z" }, - { url = "https://files.pythonhosted.org/packages/23/6f/0c86b5cb5e7ef63208c8cc22534df10ecc5278efc0d47fb8815577f3ca2f/librt-0.7.7-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c9faaebb1c6212c20afd8043cd6ed9de0a47d77f91a6b5b48f4e46ed470703fe", size = 165320, upload-time = "2026-01-01T23:51:18.455Z" }, - { url = "https://files.pythonhosted.org/packages/16/37/df4652690c29f645ffe405b58285a4109e9fe855c5bb56e817e3e75840b3/librt-0.7.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1908c3e5a5ef86b23391448b47759298f87f997c3bd153a770828f58c2bb4630", size = 174216, upload-time = "2026-01-01T23:51:19.599Z" }, - { url = "https://files.pythonhosted.org/packages/9a/d6/d3afe071910a43133ec9c0f3e4ce99ee6df0d4e44e4bddf4b9e1c6ed41cc/librt-0.7.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dbc4900e95a98fc0729523be9d93a8fedebb026f32ed9ffc08acd82e3e181503", size = 189005, upload-time = "2026-01-01T23:51:21.052Z" }, - { url = "https://files.pythonhosted.org/packages/d5/18/74060a870fe2d9fd9f47824eba6717ce7ce03124a0d1e85498e0e7efc1b2/librt-0.7.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a7ea4e1fbd253e5c68ea0fe63d08577f9d288a73f17d82f652ebc61fa48d878d", size = 183961, upload-time = "2026-01-01T23:51:22.493Z" }, - { url = "https://files.pythonhosted.org/packages/7c/5e/918a86c66304af66a3c1d46d54df1b2d0b8894babc42a14fb6f25511497f/librt-0.7.7-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:ef7699b7a5a244b1119f85c5bbc13f152cd38240cbb2baa19b769433bae98e50", size = 177610, upload-time = "2026-01-01T23:51:23.874Z" }, - { url = "https://files.pythonhosted.org/packages/b2/d7/b5e58dc2d570f162e99201b8c0151acf40a03a39c32ab824dd4febf12736/librt-0.7.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:955c62571de0b181d9e9e0a0303c8bc90d47670a5eff54cf71bf5da61d1899cf", size = 199272, upload-time = "2026-01-01T23:51:25.341Z" }, - { url = "https://files.pythonhosted.org/packages/18/87/8202c9bd0968bdddc188ec3811985f47f58ed161b3749299f2c0dd0f63fb/librt-0.7.7-cp312-cp312-win32.whl", hash = "sha256:1bcd79be209313b270b0e1a51c67ae1af28adad0e0c7e84c3ad4b5cb57aaa75b", size = 43189, upload-time = "2026-01-01T23:51:26.799Z" }, - { url = "https://files.pythonhosted.org/packages/61/8d/80244b267b585e7aa79ffdac19f66c4861effc3a24598e77909ecdd0850e/librt-0.7.7-cp312-cp312-win_amd64.whl", hash = "sha256:4353ee891a1834567e0302d4bd5e60f531912179578c36f3d0430f8c5e16b456", size = 49462, upload-time = "2026-01-01T23:51:27.813Z" }, - { url = "https://files.pythonhosted.org/packages/2d/1f/75db802d6a4992d95e8a889682601af9b49d5a13bbfa246d414eede1b56c/librt-0.7.7-cp312-cp312-win_arm64.whl", hash = "sha256:a76f1d679beccccdf8c1958e732a1dfcd6e749f8821ee59d7bec009ac308c029", size = 42828, upload-time = "2026-01-01T23:51:28.804Z" }, - { url = "https://files.pythonhosted.org/packages/8d/5e/d979ccb0a81407ec47c14ea68fb217ff4315521730033e1dd9faa4f3e2c1/librt-0.7.7-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8f4a0b0a3c86ba9193a8e23bb18f100d647bf192390ae195d84dfa0a10fb6244", size = 55746, upload-time = "2026-01-01T23:51:29.828Z" }, - { url = "https://files.pythonhosted.org/packages/f5/2c/3b65861fb32f802c3783d6ac66fc5589564d07452a47a8cf9980d531cad3/librt-0.7.7-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5335890fea9f9e6c4fdf8683061b9ccdcbe47c6dc03ab8e9b68c10acf78be78d", size = 57174, upload-time = "2026-01-01T23:51:31.226Z" }, - { url = "https://files.pythonhosted.org/packages/50/df/030b50614b29e443607220097ebaf438531ea218c7a9a3e21ea862a919cd/librt-0.7.7-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b4346b1225be26def3ccc6c965751c74868f0578cbcba293c8ae9168483d811", size = 165834, upload-time = "2026-01-01T23:51:32.278Z" }, - { url = "https://files.pythonhosted.org/packages/5d/e1/bd8d1eacacb24be26a47f157719553bbd1b3fe812c30dddf121c0436fd0b/librt-0.7.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a10b8eebdaca6e9fdbaf88b5aefc0e324b763a5f40b1266532590d5afb268a4c", size = 174819, upload-time = "2026-01-01T23:51:33.461Z" }, - { url = "https://files.pythonhosted.org/packages/46/7d/91d6c3372acf54a019c1ad8da4c9ecf4fc27d039708880bf95f48dbe426a/librt-0.7.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:067be973d90d9e319e6eb4ee2a9b9307f0ecd648b8a9002fa237289a4a07a9e7", size = 189607, upload-time = "2026-01-01T23:51:34.604Z" }, - { url = "https://files.pythonhosted.org/packages/fa/ac/44604d6d3886f791fbd1c6ae12d5a782a8f4aca927484731979f5e92c200/librt-0.7.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:23d2299ed007812cccc1ecef018db7d922733382561230de1f3954db28433977", size = 184586, upload-time = "2026-01-01T23:51:35.845Z" }, - { url = "https://files.pythonhosted.org/packages/5c/26/d8a6e4c17117b7f9b83301319d9a9de862ae56b133efb4bad8b3aa0808c9/librt-0.7.7-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6b6f8ea465524aa4c7420c7cc4ca7d46fe00981de8debc67b1cc2e9957bb5b9d", size = 178251, upload-time = "2026-01-01T23:51:37.018Z" }, - { url = "https://files.pythonhosted.org/packages/99/ab/98d857e254376f8e2f668e807daccc1f445e4b4fc2f6f9c1cc08866b0227/librt-0.7.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8df32a99cc46eb0ee90afd9ada113ae2cafe7e8d673686cf03ec53e49635439", size = 199853, upload-time = "2026-01-01T23:51:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/7c/55/4523210d6ae5134a5da959900be43ad8bab2e4206687b6620befddb5b5fd/librt-0.7.7-cp313-cp313-win32.whl", hash = "sha256:86f86b3b785487c7760247bcdac0b11aa8bf13245a13ed05206286135877564b", size = 43247, upload-time = "2026-01-01T23:51:39.629Z" }, - { url = "https://files.pythonhosted.org/packages/25/40/3ec0fed5e8e9297b1cf1a3836fb589d3de55f9930e3aba988d379e8ef67c/librt-0.7.7-cp313-cp313-win_amd64.whl", hash = "sha256:4862cb2c702b1f905c0503b72d9d4daf65a7fdf5a9e84560e563471e57a56949", size = 49419, upload-time = "2026-01-01T23:51:40.674Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7a/aab5f0fb122822e2acbc776addf8b9abfb4944a9056c00c393e46e543177/librt-0.7.7-cp313-cp313-win_arm64.whl", hash = "sha256:0996c83b1cb43c00e8c87835a284f9057bc647abd42b5871e5f941d30010c832", size = 42828, upload-time = "2026-01-01T23:51:41.731Z" }, - { url = "https://files.pythonhosted.org/packages/69/9c/228a5c1224bd23809a635490a162e9cbdc68d99f0eeb4a696f07886b8206/librt-0.7.7-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:23daa1ab0512bafdd677eb1bfc9611d8ffbe2e328895671e64cb34166bc1b8c8", size = 55188, upload-time = "2026-01-01T23:51:43.14Z" }, - { url = "https://files.pythonhosted.org/packages/ba/c2/0e7c6067e2b32a156308205e5728f4ed6478c501947e9142f525afbc6bd2/librt-0.7.7-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:558a9e5a6f3cc1e20b3168fb1dc802d0d8fa40731f6e9932dcc52bbcfbd37111", size = 56895, upload-time = "2026-01-01T23:51:44.534Z" }, - { url = "https://files.pythonhosted.org/packages/0e/77/de50ff70c80855eb79d1d74035ef06f664dd073fb7fb9d9fb4429651b8eb/librt-0.7.7-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2567cb48dc03e5b246927ab35cbb343376e24501260a9b5e30b8e255dca0d1d2", size = 163724, upload-time = "2026-01-01T23:51:45.571Z" }, - { url = "https://files.pythonhosted.org/packages/6e/19/f8e4bf537899bdef9e0bb9f0e4b18912c2d0f858ad02091b6019864c9a6d/librt-0.7.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6066c638cdf85ff92fc6f932d2d73c93a0e03492cdfa8778e6d58c489a3d7259", size = 172470, upload-time = "2026-01-01T23:51:46.823Z" }, - { url = "https://files.pythonhosted.org/packages/42/4c/dcc575b69d99076768e8dd6141d9aecd4234cba7f0e09217937f52edb6ed/librt-0.7.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a609849aca463074c17de9cda173c276eb8fee9e441053529e7b9e249dc8b8ee", size = 186806, upload-time = "2026-01-01T23:51:48.009Z" }, - { url = "https://files.pythonhosted.org/packages/fe/f8/4094a2b7816c88de81239a83ede6e87f1138477d7ee956c30f136009eb29/librt-0.7.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:add4e0a000858fe9bb39ed55f31085506a5c38363e6eb4a1e5943a10c2bfc3d1", size = 181809, upload-time = "2026-01-01T23:51:49.35Z" }, - { url = "https://files.pythonhosted.org/packages/1b/ac/821b7c0ab1b5a6cd9aee7ace8309c91545a2607185101827f79122219a7e/librt-0.7.7-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a3bfe73a32bd0bdb9a87d586b05a23c0a1729205d79df66dee65bb2e40d671ba", size = 175597, upload-time = "2026-01-01T23:51:50.636Z" }, - { url = "https://files.pythonhosted.org/packages/71/f9/27f6bfbcc764805864c04211c6ed636fe1d58f57a7b68d1f4ae5ed74e0e0/librt-0.7.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ecce0544d3db91a40f8b57ae26928c02130a997b540f908cefd4d279d6c5848", size = 196506, upload-time = "2026-01-01T23:51:52.535Z" }, - { url = "https://files.pythonhosted.org/packages/46/ba/c9b9c6fc931dd7ea856c573174ccaf48714905b1a7499904db2552e3bbaf/librt-0.7.7-cp314-cp314-win32.whl", hash = "sha256:8f7a74cf3a80f0c3b0ec75b0c650b2f0a894a2cec57ef75f6f72c1e82cdac61d", size = 39747, upload-time = "2026-01-01T23:51:53.683Z" }, - { url = "https://files.pythonhosted.org/packages/c5/69/cd1269337c4cde3ee70176ee611ab0058aa42fc8ce5c9dce55f48facfcd8/librt-0.7.7-cp314-cp314-win_amd64.whl", hash = "sha256:3d1fe2e8df3268dd6734dba33ededae72ad5c3a859b9577bc00b715759c5aaab", size = 45971, upload-time = "2026-01-01T23:51:54.697Z" }, - { url = "https://files.pythonhosted.org/packages/79/fd/e0844794423f5583108c5991313c15e2b400995f44f6ec6871f8aaf8243c/librt-0.7.7-cp314-cp314-win_arm64.whl", hash = "sha256:2987cf827011907d3dfd109f1be0d61e173d68b1270107bb0e89f2fca7f2ed6b", size = 39075, upload-time = "2026-01-01T23:51:55.726Z" }, - { url = "https://files.pythonhosted.org/packages/42/02/211fd8f7c381e7b2a11d0fdfcd410f409e89967be2e705983f7c6342209a/librt-0.7.7-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8e92c8de62b40bfce91d5e12c6e8b15434da268979b1af1a6589463549d491e6", size = 57368, upload-time = "2026-01-01T23:51:56.706Z" }, - { url = "https://files.pythonhosted.org/packages/4c/b6/aca257affae73ece26041ae76032153266d110453173f67d7603058e708c/librt-0.7.7-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f683dcd49e2494a7535e30f779aa1ad6e3732a019d80abe1309ea91ccd3230e3", size = 59238, upload-time = "2026-01-01T23:51:58.066Z" }, - { url = "https://files.pythonhosted.org/packages/96/47/7383a507d8e0c11c78ca34c9d36eab9000db5989d446a2f05dc40e76c64f/librt-0.7.7-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9b15e5d17812d4d629ff576699954f74e2cc24a02a4fc401882dd94f81daba45", size = 183870, upload-time = "2026-01-01T23:51:59.204Z" }, - { url = "https://files.pythonhosted.org/packages/a4/b8/50f3d8eec8efdaf79443963624175c92cec0ba84827a66b7fcfa78598e51/librt-0.7.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c084841b879c4d9b9fa34e5d5263994f21aea7fd9c6add29194dbb41a6210536", size = 194608, upload-time = "2026-01-01T23:52:00.419Z" }, - { url = "https://files.pythonhosted.org/packages/23/d9/1b6520793aadb59d891e3b98ee057a75de7f737e4a8b4b37fdbecb10d60f/librt-0.7.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c8fb9966f84737115513fecbaf257f9553d067a7dd45a69c2c7e5339e6a8dc", size = 206776, upload-time = "2026-01-01T23:52:01.705Z" }, - { url = "https://files.pythonhosted.org/packages/ff/db/331edc3bba929d2756fa335bfcf736f36eff4efcb4f2600b545a35c2ae58/librt-0.7.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9b5fb1ecb2c35362eab2dbd354fd1efa5a8440d3e73a68be11921042a0edc0ff", size = 203206, upload-time = "2026-01-01T23:52:03.315Z" }, - { url = "https://files.pythonhosted.org/packages/b2/e1/6af79ec77204e85f6f2294fc171a30a91bb0e35d78493532ed680f5d98be/librt-0.7.7-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d1454899909d63cc9199a89fcc4f81bdd9004aef577d4ffc022e600c412d57f3", size = 196697, upload-time = "2026-01-01T23:52:04.857Z" }, - { url = "https://files.pythonhosted.org/packages/f3/46/de55ecce4b2796d6d243295c221082ca3a944dc2fb3a52dcc8660ce7727d/librt-0.7.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7ef28f2e7a016b29792fe0a2dd04dec75725b32a1264e390c366103f834a9c3a", size = 217193, upload-time = "2026-01-01T23:52:06.159Z" }, - { url = "https://files.pythonhosted.org/packages/41/61/33063e271949787a2f8dd33c5260357e3d512a114fc82ca7890b65a76e2d/librt-0.7.7-cp314-cp314t-win32.whl", hash = "sha256:5e419e0db70991b6ba037b70c1d5bbe92b20ddf82f31ad01d77a347ed9781398", size = 40277, upload-time = "2026-01-01T23:52:07.625Z" }, - { url = "https://files.pythonhosted.org/packages/06/21/1abd972349f83a696ea73159ac964e63e2d14086fdd9bc7ca878c25fced4/librt-0.7.7-cp314-cp314t-win_amd64.whl", hash = "sha256:d6b7d93657332c817b8d674ef6bf1ab7796b4f7ce05e420fd45bd258a72ac804", size = 46765, upload-time = "2026-01-01T23:52:08.647Z" }, - { url = "https://files.pythonhosted.org/packages/51/0e/b756c7708143a63fca65a51ca07990fa647db2cc8fcd65177b9e96680255/librt-0.7.7-cp314-cp314t-win_arm64.whl", hash = "sha256:142c2cd91794b79fd0ce113bd658993b7ede0fe93057668c2f98a45ca00b7e91", size = 39724, upload-time = "2026-01-01T23:52:09.745Z" }, +version = "0.7.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/24/5f3646ff414285e0f7708fa4e946b9bf538345a41d1c375c439467721a5e/librt-0.7.8.tar.gz", hash = "sha256:1a4ede613941d9c3470b0368be851df6bb78ab218635512d0370b27a277a0862", size = 148323, upload-time = "2026-01-14T12:56:16.876Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/44/13/57b06758a13550c5f09563893b004f98e9537ee6ec67b7df85c3571c8832/librt-0.7.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b45306a1fc5f53c9330fbee134d8b3227fe5da2ab09813b892790400aa49352d", size = 56521, upload-time = "2026-01-14T12:54:40.066Z" }, + { url = "https://files.pythonhosted.org/packages/c2/24/bbea34d1452a10612fb45ac8356f95351ba40c2517e429602160a49d1fd0/librt-0.7.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:864c4b7083eeee250ed55135d2127b260d7eb4b5e953a9e5df09c852e327961b", size = 58456, upload-time = "2026-01-14T12:54:41.471Z" }, + { url = "https://files.pythonhosted.org/packages/04/72/a168808f92253ec3a810beb1eceebc465701197dbc7e865a1c9ceb3c22c7/librt-0.7.8-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6938cc2de153bc927ed8d71c7d2f2ae01b4e96359126c602721340eb7ce1a92d", size = 164392, upload-time = "2026-01-14T12:54:42.843Z" }, + { url = "https://files.pythonhosted.org/packages/14/5c/4c0d406f1b02735c2e7af8ff1ff03a6577b1369b91aa934a9fa2cc42c7ce/librt-0.7.8-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:66daa6ac5de4288a5bbfbe55b4caa7bf0cd26b3269c7a476ffe8ce45f837f87d", size = 172959, upload-time = "2026-01-14T12:54:44.602Z" }, + { url = "https://files.pythonhosted.org/packages/82/5f/3e85351c523f73ad8d938989e9a58c7f59fb9c17f761b9981b43f0025ce7/librt-0.7.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4864045f49dc9c974dadb942ac56a74cd0479a2aafa51ce272c490a82322ea3c", size = 186717, upload-time = "2026-01-14T12:54:45.986Z" }, + { url = "https://files.pythonhosted.org/packages/08/f8/18bfe092e402d00fe00d33aa1e01dda1bd583ca100b393b4373847eade6d/librt-0.7.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a36515b1328dc5b3ffce79fe204985ca8572525452eacabee2166f44bb387b2c", size = 184585, upload-time = "2026-01-14T12:54:47.139Z" }, + { url = "https://files.pythonhosted.org/packages/4e/fc/f43972ff56fd790a9fa55028a52ccea1875100edbb856b705bd393b601e3/librt-0.7.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:b7e7f140c5169798f90b80d6e607ed2ba5059784968a004107c88ad61fb3641d", size = 180497, upload-time = "2026-01-14T12:54:48.946Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3a/25e36030315a410d3ad0b7d0f19f5f188e88d1613d7d3fd8150523ea1093/librt-0.7.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ff71447cb778a4f772ddc4ce360e6ba9c95527ed84a52096bd1bbf9fee2ec7c0", size = 200052, upload-time = "2026-01-14T12:54:50.382Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b8/f3a5a1931ae2a6ad92bf6893b9ef44325b88641d58723529e2c2935e8abe/librt-0.7.8-cp310-cp310-win32.whl", hash = "sha256:047164e5f68b7a8ebdf9fae91a3c2161d3192418aadd61ddd3a86a56cbe3dc85", size = 43477, upload-time = "2026-01-14T12:54:51.815Z" }, + { url = "https://files.pythonhosted.org/packages/fe/91/c4202779366bc19f871b4ad25db10fcfa1e313c7893feb942f32668e8597/librt-0.7.8-cp310-cp310-win_amd64.whl", hash = "sha256:d6f254d096d84156a46a84861183c183d30734e52383602443292644d895047c", size = 49806, upload-time = "2026-01-14T12:54:53.149Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a3/87ea9c1049f2c781177496ebee29430e4631f439b8553a4969c88747d5d8/librt-0.7.8-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ff3e9c11aa260c31493d4b3197d1e28dd07768594a4f92bec4506849d736248f", size = 56507, upload-time = "2026-01-14T12:54:54.156Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4a/23bcef149f37f771ad30203d561fcfd45b02bc54947b91f7a9ac34815747/librt-0.7.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ddb52499d0b3ed4aa88746aaf6f36a08314677d5c346234c3987ddc506404eac", size = 58455, upload-time = "2026-01-14T12:54:55.978Z" }, + { url = "https://files.pythonhosted.org/packages/22/6e/46eb9b85c1b9761e0f42b6e6311e1cc544843ac897457062b9d5d0b21df4/librt-0.7.8-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e9c0afebbe6ce177ae8edba0c7c4d626f2a0fc12c33bb993d163817c41a7a05c", size = 164956, upload-time = "2026-01-14T12:54:57.311Z" }, + { url = "https://files.pythonhosted.org/packages/7a/3f/aa7c7f6829fb83989feb7ba9aa11c662b34b4bd4bd5b262f2876ba3db58d/librt-0.7.8-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:631599598e2c76ded400c0a8722dec09217c89ff64dc54b060f598ed68e7d2a8", size = 174364, upload-time = "2026-01-14T12:54:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2d/d57d154b40b11f2cb851c4df0d4c4456bacd9b1ccc4ecb593ddec56c1a8b/librt-0.7.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c1ba843ae20db09b9d5c80475376168feb2640ce91cd9906414f23cc267a1ff", size = 188034, upload-time = "2026-01-14T12:55:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/59/f9/36c4dad00925c16cd69d744b87f7001792691857d3b79187e7a673e812fb/librt-0.7.8-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b5b007bb22ea4b255d3ee39dfd06d12534de2fcc3438567d9f48cdaf67ae1ae3", size = 186295, upload-time = "2026-01-14T12:55:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/8a9889d3df5efb67695a67785028ccd58e661c3018237b73ad081691d0cb/librt-0.7.8-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:dbd79caaf77a3f590cbe32dc2447f718772d6eea59656a7dcb9311161b10fa75", size = 181470, upload-time = "2026-01-14T12:55:02.492Z" }, + { url = "https://files.pythonhosted.org/packages/43/64/54d6ef11afca01fef8af78c230726a9394759f2addfbf7afc5e3cc032a45/librt-0.7.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:87808a8d1e0bd62a01cafc41f0fd6818b5a5d0ca0d8a55326a81643cdda8f873", size = 201713, upload-time = "2026-01-14T12:55:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/2d/29/73e7ed2991330b28919387656f54109139b49e19cd72902f466bd44415fd/librt-0.7.8-cp311-cp311-win32.whl", hash = "sha256:31724b93baa91512bd0a376e7cf0b59d8b631ee17923b1218a65456fa9bda2e7", size = 43803, upload-time = "2026-01-14T12:55:04.996Z" }, + { url = "https://files.pythonhosted.org/packages/3f/de/66766ff48ed02b4d78deea30392ae200bcbd99ae61ba2418b49fd50a4831/librt-0.7.8-cp311-cp311-win_amd64.whl", hash = "sha256:978e8b5f13e52cf23a9e80f3286d7546baa70bc4ef35b51d97a709d0b28e537c", size = 50080, upload-time = "2026-01-14T12:55:06.489Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e3/33450438ff3a8c581d4ed7f798a70b07c3206d298cf0b87d3806e72e3ed8/librt-0.7.8-cp311-cp311-win_arm64.whl", hash = "sha256:20e3946863d872f7cabf7f77c6c9d370b8b3d74333d3a32471c50d3a86c0a232", size = 43383, upload-time = "2026-01-14T12:55:07.49Z" }, + { url = "https://files.pythonhosted.org/packages/56/04/79d8fcb43cae376c7adbab7b2b9f65e48432c9eced62ac96703bcc16e09b/librt-0.7.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9b6943885b2d49c48d0cff23b16be830ba46b0152d98f62de49e735c6e655a63", size = 57472, upload-time = "2026-01-14T12:55:08.528Z" }, + { url = "https://files.pythonhosted.org/packages/b4/ba/60b96e93043d3d659da91752689023a73981336446ae82078cddf706249e/librt-0.7.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:46ef1f4b9b6cc364b11eea0ecc0897314447a66029ee1e55859acb3dd8757c93", size = 58986, upload-time = "2026-01-14T12:55:09.466Z" }, + { url = "https://files.pythonhosted.org/packages/7c/26/5215e4cdcc26e7be7eee21955a7e13cbf1f6d7d7311461a6014544596fac/librt-0.7.8-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:907ad09cfab21e3c86e8f1f87858f7049d1097f77196959c033612f532b4e592", size = 168422, upload-time = "2026-01-14T12:55:10.499Z" }, + { url = "https://files.pythonhosted.org/packages/0f/84/e8d1bc86fa0159bfc24f3d798d92cafd3897e84c7fea7fe61b3220915d76/librt-0.7.8-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2991b6c3775383752b3ca0204842743256f3ad3deeb1d0adc227d56b78a9a850", size = 177478, upload-time = "2026-01-14T12:55:11.577Z" }, + { url = "https://files.pythonhosted.org/packages/57/11/d0268c4b94717a18aa91df1100e767b010f87b7ae444dafaa5a2d80f33a6/librt-0.7.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:03679b9856932b8c8f674e87aa3c55ea11c9274301f76ae8dc4d281bda55cf62", size = 192439, upload-time = "2026-01-14T12:55:12.7Z" }, + { url = "https://files.pythonhosted.org/packages/8d/56/1e8e833b95fe684f80f8894ae4d8b7d36acc9203e60478fcae599120a975/librt-0.7.8-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3968762fec1b2ad34ce57458b6de25dbb4142713e9ca6279a0d352fa4e9f452b", size = 191483, upload-time = "2026-01-14T12:55:13.838Z" }, + { url = "https://files.pythonhosted.org/packages/17/48/f11cf28a2cb6c31f282009e2208312aa84a5ee2732859f7856ee306176d5/librt-0.7.8-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:bb7a7807523a31f03061288cc4ffc065d684c39db7644c676b47d89553c0d714", size = 185376, upload-time = "2026-01-14T12:55:15.017Z" }, + { url = "https://files.pythonhosted.org/packages/b8/6a/d7c116c6da561b9155b184354a60a3d5cdbf08fc7f3678d09c95679d13d9/librt-0.7.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad64a14b1e56e702e19b24aae108f18ad1bf7777f3af5fcd39f87d0c5a814449", size = 206234, upload-time = "2026-01-14T12:55:16.571Z" }, + { url = "https://files.pythonhosted.org/packages/61/de/1975200bb0285fc921c5981d9978ce6ce11ae6d797df815add94a5a848a3/librt-0.7.8-cp312-cp312-win32.whl", hash = "sha256:0241a6ed65e6666236ea78203a73d800dbed896cf12ae25d026d75dc1fcd1dac", size = 44057, upload-time = "2026-01-14T12:55:18.077Z" }, + { url = "https://files.pythonhosted.org/packages/8e/cd/724f2d0b3461426730d4877754b65d39f06a41ac9d0a92d5c6840f72b9ae/librt-0.7.8-cp312-cp312-win_amd64.whl", hash = "sha256:6db5faf064b5bab9675c32a873436b31e01d66ca6984c6f7f92621656033a708", size = 50293, upload-time = "2026-01-14T12:55:19.179Z" }, + { url = "https://files.pythonhosted.org/packages/bd/cf/7e899acd9ee5727ad8160fdcc9994954e79fab371c66535c60e13b968ffc/librt-0.7.8-cp312-cp312-win_arm64.whl", hash = "sha256:57175aa93f804d2c08d2edb7213e09276bd49097611aefc37e3fa38d1fb99ad0", size = 43574, upload-time = "2026-01-14T12:55:20.185Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fe/b1f9de2829cf7fc7649c1dcd202cfd873837c5cc2fc9e526b0e7f716c3d2/librt-0.7.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4c3995abbbb60b3c129490fa985dfe6cac11d88fc3c36eeb4fb1449efbbb04fc", size = 57500, upload-time = "2026-01-14T12:55:21.219Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d4/4a60fbe2e53b825f5d9a77325071d61cd8af8506255067bf0c8527530745/librt-0.7.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:44e0c2cbc9bebd074cf2cdbe472ca185e824be4e74b1c63a8e934cea674bebf2", size = 59019, upload-time = "2026-01-14T12:55:22.256Z" }, + { url = "https://files.pythonhosted.org/packages/6a/37/61ff80341ba5159afa524445f2d984c30e2821f31f7c73cf166dcafa5564/librt-0.7.8-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d2f1e492cae964b3463a03dc77a7fe8742f7855d7258c7643f0ee32b6651dd3", size = 169015, upload-time = "2026-01-14T12:55:23.24Z" }, + { url = "https://files.pythonhosted.org/packages/1c/86/13d4f2d6a93f181ebf2fc953868826653ede494559da8268023fe567fca3/librt-0.7.8-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:451e7ffcef8f785831fdb791bd69211f47e95dc4c6ddff68e589058806f044c6", size = 178161, upload-time = "2026-01-14T12:55:24.826Z" }, + { url = "https://files.pythonhosted.org/packages/88/26/e24ef01305954fc4d771f1f09f3dd682f9eb610e1bec188ffb719374d26e/librt-0.7.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3469e1af9f1380e093ae06bedcbdd11e407ac0b303a56bbe9afb1d6824d4982d", size = 193015, upload-time = "2026-01-14T12:55:26.04Z" }, + { url = "https://files.pythonhosted.org/packages/88/a0/92b6bd060e720d7a31ed474d046a69bd55334ec05e9c446d228c4b806ae3/librt-0.7.8-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f11b300027ce19a34f6d24ebb0a25fd0e24a9d53353225a5c1e6cadbf2916b2e", size = 192038, upload-time = "2026-01-14T12:55:27.208Z" }, + { url = "https://files.pythonhosted.org/packages/06/bb/6f4c650253704279c3a214dad188101d1b5ea23be0606628bc6739456624/librt-0.7.8-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:4adc73614f0d3c97874f02f2c7fd2a27854e7e24ad532ea6b965459c5b757eca", size = 186006, upload-time = "2026-01-14T12:55:28.594Z" }, + { url = "https://files.pythonhosted.org/packages/dc/00/1c409618248d43240cadf45f3efb866837fa77e9a12a71481912135eb481/librt-0.7.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:60c299e555f87e4c01b2eca085dfccda1dde87f5a604bb45c2906b8305819a93", size = 206888, upload-time = "2026-01-14T12:55:30.214Z" }, + { url = "https://files.pythonhosted.org/packages/d9/83/b2cfe8e76ff5c1c77f8a53da3d5de62d04b5ebf7cf913e37f8bca43b5d07/librt-0.7.8-cp313-cp313-win32.whl", hash = "sha256:b09c52ed43a461994716082ee7d87618096851319bf695d57ec123f2ab708951", size = 44126, upload-time = "2026-01-14T12:55:31.44Z" }, + { url = "https://files.pythonhosted.org/packages/a9/0b/c59d45de56a51bd2d3a401fc63449c0ac163e4ef7f523ea8b0c0dee86ec5/librt-0.7.8-cp313-cp313-win_amd64.whl", hash = "sha256:f8f4a901a3fa28969d6e4519deceab56c55a09d691ea7b12ca830e2fa3461e34", size = 50262, upload-time = "2026-01-14T12:55:33.01Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b9/973455cec0a1ec592395250c474164c4a58ebf3e0651ee920fef1a2623f1/librt-0.7.8-cp313-cp313-win_arm64.whl", hash = "sha256:43d4e71b50763fcdcf64725ac680d8cfa1706c928b844794a7aa0fa9ac8e5f09", size = 43600, upload-time = "2026-01-14T12:55:34.054Z" }, + { url = "https://files.pythonhosted.org/packages/1a/73/fa8814c6ce2d49c3827829cadaa1589b0bf4391660bd4510899393a23ebc/librt-0.7.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:be927c3c94c74b05128089a955fba86501c3b544d1d300282cc1b4bd370cb418", size = 57049, upload-time = "2026-01-14T12:55:35.056Z" }, + { url = "https://files.pythonhosted.org/packages/53/fe/f6c70956da23ea235fd2e3cc16f4f0b4ebdfd72252b02d1164dd58b4e6c3/librt-0.7.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7b0803e9008c62a7ef79058233db7ff6f37a9933b8f2573c05b07ddafa226611", size = 58689, upload-time = "2026-01-14T12:55:36.078Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4d/7a2481444ac5fba63050d9abe823e6bc16896f575bfc9c1e5068d516cdce/librt-0.7.8-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:79feb4d00b2a4e0e05c9c56df707934f41fcb5fe53fd9efb7549068d0495b758", size = 166808, upload-time = "2026-01-14T12:55:37.595Z" }, + { url = "https://files.pythonhosted.org/packages/ac/3c/10901d9e18639f8953f57c8986796cfbf4c1c514844a41c9197cf87cb707/librt-0.7.8-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b9122094e3f24aa759c38f46bd8863433820654927370250f460ae75488b66ea", size = 175614, upload-time = "2026-01-14T12:55:38.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/01/5cbdde0951a5090a80e5ba44e6357d375048123c572a23eecfb9326993a7/librt-0.7.8-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e03bea66af33c95ce3addf87a9bf1fcad8d33e757bc479957ddbc0e4f7207ac", size = 189955, upload-time = "2026-01-14T12:55:39.939Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b4/e80528d2f4b7eaf1d437fcbd6fc6ba4cbeb3e2a0cb9ed5a79f47c7318706/librt-0.7.8-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1ade7f31675db00b514b98f9ab9a7698c7282dad4be7492589109471852d398", size = 189370, upload-time = "2026-01-14T12:55:41.057Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ab/938368f8ce31a9787ecd4becb1e795954782e4312095daf8fd22420227c8/librt-0.7.8-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a14229ac62adcf1b90a15992f1ab9c69ae8b99ffb23cb64a90878a6e8a2f5b81", size = 183224, upload-time = "2026-01-14T12:55:42.328Z" }, + { url = "https://files.pythonhosted.org/packages/3c/10/559c310e7a6e4014ac44867d359ef8238465fb499e7eb31b6bfe3e3f86f5/librt-0.7.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5bcaaf624fd24e6a0cb14beac37677f90793a96864c67c064a91458611446e83", size = 203541, upload-time = "2026-01-14T12:55:43.501Z" }, + { url = "https://files.pythonhosted.org/packages/f8/db/a0db7acdb6290c215f343835c6efda5b491bb05c3ddc675af558f50fdba3/librt-0.7.8-cp314-cp314-win32.whl", hash = "sha256:7aa7d5457b6c542ecaed79cec4ad98534373c9757383973e638ccced0f11f46d", size = 40657, upload-time = "2026-01-14T12:55:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/72/e0/4f9bdc2a98a798511e81edcd6b54fe82767a715e05d1921115ac70717f6f/librt-0.7.8-cp314-cp314-win_amd64.whl", hash = "sha256:3d1322800771bee4a91f3b4bd4e49abc7d35e65166821086e5afd1e6c0d9be44", size = 46835, upload-time = "2026-01-14T12:55:45.655Z" }, + { url = "https://files.pythonhosted.org/packages/f9/3d/59c6402e3dec2719655a41ad027a7371f8e2334aa794ed11533ad5f34969/librt-0.7.8-cp314-cp314-win_arm64.whl", hash = "sha256:5363427bc6a8c3b1719f8f3845ea53553d301382928a86e8fab7984426949bce", size = 39885, upload-time = "2026-01-14T12:55:47.138Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9c/2481d80950b83085fb14ba3c595db56330d21bbc7d88a19f20165f3538db/librt-0.7.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:ca916919793a77e4a98d4a1701e345d337ce53be4a16620f063191f7322ac80f", size = 59161, upload-time = "2026-01-14T12:55:48.45Z" }, + { url = "https://files.pythonhosted.org/packages/96/79/108df2cfc4e672336765d54e3ff887294c1cc36ea4335c73588875775527/librt-0.7.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:54feb7b4f2f6706bb82325e836a01be805770443e2400f706e824e91f6441dde", size = 61008, upload-time = "2026-01-14T12:55:49.527Z" }, + { url = "https://files.pythonhosted.org/packages/46/f2/30179898f9994a5637459d6e169b6abdc982012c0a4b2d4c26f50c06f911/librt-0.7.8-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:39a4c76fee41007070f872b648cc2f711f9abf9a13d0c7162478043377b52c8e", size = 187199, upload-time = "2026-01-14T12:55:50.587Z" }, + { url = "https://files.pythonhosted.org/packages/b4/da/f7563db55cebdc884f518ba3791ad033becc25ff68eb70902b1747dc0d70/librt-0.7.8-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac9c8a458245c7de80bc1b9765b177055efff5803f08e548dd4bb9ab9a8d789b", size = 198317, upload-time = "2026-01-14T12:55:51.991Z" }, + { url = "https://files.pythonhosted.org/packages/b3/6c/4289acf076ad371471fa86718c30ae353e690d3de6167f7db36f429272f1/librt-0.7.8-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:95b67aa7eff150f075fda09d11f6bfb26edffd300f6ab1666759547581e8f666", size = 210334, upload-time = "2026-01-14T12:55:53.682Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7f/377521ac25b78ac0a5ff44127a0360ee6d5ddd3ce7327949876a30533daa/librt-0.7.8-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:535929b6eff670c593c34ff435d5440c3096f20fa72d63444608a5aef64dd581", size = 211031, upload-time = "2026-01-14T12:55:54.827Z" }, + { url = "https://files.pythonhosted.org/packages/c5/b1/e1e96c3e20b23d00cf90f4aad48f0deb4cdfec2f0ed8380d0d85acf98bbf/librt-0.7.8-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:63937bd0f4d1cb56653dc7ae900d6c52c41f0015e25aaf9902481ee79943b33a", size = 204581, upload-time = "2026-01-14T12:55:56.811Z" }, + { url = "https://files.pythonhosted.org/packages/43/71/0f5d010e92ed9747e14bef35e91b6580533510f1e36a8a09eb79ee70b2f0/librt-0.7.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cf243da9e42d914036fd362ac3fa77d80a41cadcd11ad789b1b5eec4daaf67ca", size = 224731, upload-time = "2026-01-14T12:55:58.175Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/07fb6ab5c39a4ca9af3e37554f9d42f25c464829254d72e4ebbd81da351c/librt-0.7.8-cp314-cp314t-win32.whl", hash = "sha256:171ca3a0a06c643bd0a2f62a8944e1902c94aa8e5da4db1ea9a8daf872685365", size = 41173, upload-time = "2026-01-14T12:55:59.315Z" }, + { url = "https://files.pythonhosted.org/packages/24/d4/7e4be20993dc6a782639625bd2f97f3c66125c7aa80c82426956811cfccf/librt-0.7.8-cp314-cp314t-win_amd64.whl", hash = "sha256:445b7304145e24c60288a2f172b5ce2ca35c0f81605f5299f3fa567e189d2e32", size = 47668, upload-time = "2026-01-14T12:56:00.261Z" }, + { url = "https://files.pythonhosted.org/packages/fc/85/69f92b2a7b3c0f88ffe107c86b952b397004b5b8ea5a81da3d9c04c04422/librt-0.7.8-cp314-cp314t-win_arm64.whl", hash = "sha256:8766ece9de08527deabcd7cb1b4f1a967a385d26e33e536d6d8913db6ef74f06", size = 40550, upload-time = "2026-01-14T12:56:01.542Z" }, ] [[package]] name = "litellm" -version = "1.80.15" +version = "1.80.16" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3154,9 +3154,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/12/41/9b28df3e4739df83ddb32dfb2bccb12ad271d986494c9fd60e4927a0a6c3/litellm-1.80.15.tar.gz", hash = "sha256:759d09f33c9c6028c58dcdf71781b17b833ee926525714e09a408602be27f54e", size = 13376508, upload-time = "2026-01-11T18:31:44.95Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/cc/03bf7849c62587db0fb7c46f427d6a49290750752d3189a0bd95d4b78587/litellm-1.80.16.tar.gz", hash = "sha256:f96233649f99ab097f7d8a3ff9898680207b9eea7d2e23f438074a3dbcf50cca", size = 13384256, upload-time = "2026-01-13T08:52:23.067Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/df/3b/b1bd693721ccb3c9a37c8233d019a643ac57bef5a93f279e5a63839ee4db/litellm-1.80.15-py3-none-any.whl", hash = "sha256:f354e49456985a235b9ed99df1c19d686d30501f96e68882dcc5b29b1e7c59d9", size = 11670707, upload-time = "2026-01-11T18:31:41.67Z" }, + { url = "https://files.pythonhosted.org/packages/53/4d/73fdb12223bdb01889134eb75525fcc768b1724255f2b87072dd6743c6e1/litellm-1.80.16-py3-none-any.whl", hash = "sha256:21be641b350561b293b831addb25249676b72ebff973a5a1d73b5d7cf35bcd1d", size = 11682530, upload-time = "2026-01-13T08:52:19.951Z" }, ] [package.optional-dependencies] @@ -3431,7 +3431,7 @@ wheels = [ [[package]] name = "mem0ai" -version = "1.0.1" +version = "1.0.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "openai", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3442,9 +3442,9 @@ dependencies = [ { name = "qdrant-client", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "sqlalchemy", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/cd/f9047cd45952af08da8084c2297f8aad780f9ac8558631fc64b3ed235b28/mem0ai-1.0.1.tar.gz", hash = "sha256:53be77f479387e6c07508096eb6c0688150b31152613bdcf6c281246b000b14d", size = 182296, upload-time = "2025-11-13T22:32:13.658Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4c/b3/57edb1253e7dc24d41e102722a585d6e08a96c6191a6a04e43112c01dc5d/mem0ai-1.0.2.tar.gz", hash = "sha256:533c370e8a4e817d47a583cb7fa4df55db59de8dd67be39f2b927e2ad19607d1", size = 182395, upload-time = "2026-01-13T07:40:00.666Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/81/42/120d6db33e190ef09d69428ddd2eaaa87e10f4c8243af788f5fc524748c9/mem0ai-1.0.1-py3-none-any.whl", hash = "sha256:a8eeca9688e87f175af53d463b4a3b2d552984c81e29bc656c847dc04eaf6f75", size = 275351, upload-time = "2025-11-13T22:32:11.839Z" }, + { url = "https://files.pythonhosted.org/packages/d7/82/59309070bd2d2ddccebd89d8ebb7a2155ce12531f0c36123d0a39eada544/mem0ai-1.0.2-py3-none-any.whl", hash = "sha256:3528523653bc57efa477d55e703dcedf8decc23868d4dbcc6d43a97f2315834a", size = 275428, upload-time = "2026-01-13T07:39:58.339Z" }, ] [[package]] @@ -4470,15 +4470,15 @@ wheels = [ [[package]] name = "plotly" -version = "6.5.1" +version = "6.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "narwhals", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "packaging", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/d6/ff/a4938b75e95114451efdb34db6b41930253e67efc8dc737bd592ef2e419d/plotly-6.5.1.tar.gz", hash = "sha256:b0478c8d5ada0c8756bce15315bcbfec7d3ab8d24614e34af9aff7bfcfea9281", size = 7014606, upload-time = "2026-01-07T20:11:41.644Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/4f/8a10a9b9f5192cb6fdef62f1d77fa7d834190b2c50c0cd256bd62879212b/plotly-6.5.2.tar.gz", hash = "sha256:7478555be0198562d1435dee4c308268187553cc15516a2f4dd034453699e393", size = 7015695, upload-time = "2026-01-14T21:26:51.222Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e9/8e/24e0bb90b2d75af84820693260c5534e9ed351afdda67ed6f393a141a0e2/plotly-6.5.1-py3-none-any.whl", hash = "sha256:5adad4f58c360612b6c5ce11a308cdbc4fd38ceb1d40594a614f0062e227abe1", size = 9894981, upload-time = "2026-01-07T20:11:38.124Z" }, + { url = "https://files.pythonhosted.org/packages/8a/67/f95b5460f127840310d2187f916cf0023b5875c0717fdf893f71e1325e87/plotly-6.5.2-py3-none-any.whl", hash = "sha256:91757653bd9c550eeea2fa2404dba6b85d1e366d54804c340b2c874e5a7eb4a4", size = 9895973, upload-time = "2026-01-14T21:26:47.135Z" }, ] [[package]] @@ -4515,30 +4515,30 @@ wheels = [ [[package]] name = "polars" -version = "1.37.0" +version = "1.37.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6c/b5/ce40267c54b66f93572d84f7ba1c216b72a71cb2235e3724fab0911541fe/polars-1.37.0.tar.gz", hash = "sha256:6bbbeefb6f02f848d46ad4f4e922a92573986fd38611801c696bae98b02be4c8", size = 715429, upload-time = "2026-01-10T12:28:06.741Z" } +sdist = { url = "https://files.pythonhosted.org/packages/84/ae/dfebf31b9988c20998140b54d5b521f64ce08879f2c13d9b4d44d7c87e32/polars-1.37.1.tar.gz", hash = "sha256:0309e2a4633e712513401964b4d95452f124ceabf7aec6db50affb9ced4a274e", size = 715572, upload-time = "2026-01-12T23:27:03.267Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/07/d890382bbfdeb25db039ef4a8c8f93b3faf0016e18130513274204954203/polars-1.37.0-py3-none-any.whl", hash = "sha256:fcc549b9923ef1bd6fd99b5fd0a00dfedf85406f4758ae018a69bcd18a91f113", size = 805614, upload-time = "2026-01-10T12:26:47.897Z" }, + { url = "https://files.pythonhosted.org/packages/08/75/ec73e38812bca7c2240aff481b9ddff20d1ad2f10dee4b3353f5eeaacdab/polars-1.37.1-py3-none-any.whl", hash = "sha256:377fed8939a2f1223c1563cfabdc7b4a3d6ff846efa1f2ddeb8644fafd9b1aff", size = 805749, upload-time = "2026-01-12T23:25:48.595Z" }, ] [[package]] name = "polars-runtime-32" -version = "1.37.0" +version = "1.37.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/92/b818590a5ebcc55657f5483f26133174bd2b9ca88457b60c93669a9d0c75/polars_runtime_32-1.37.0.tar.gz", hash = "sha256:954ddb056e3a2db2cbcaae501225ac5604d1599b6debd9c6dbdf8efbac0e6511", size = 2820371, upload-time = "2026-01-10T12:28:08.195Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/0b/addabe5e8d28a5a4c9887a08907be7ddc3fce892dc38f37d14b055438a57/polars_runtime_32-1.37.1.tar.gz", hash = "sha256:68779d4a691da20a5eb767d74165a8f80a2bdfbde4b54acf59af43f7fa028d8f", size = 2818945, upload-time = "2026-01-12T23:27:04.653Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f0/67/76162c9fcc71b917bdfd2804eaf0ab7cdb264a89b89af4f195a918f9f97d/polars_runtime_32-1.37.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:3591f4b8e734126d713a12869d3727360acbbcd1d440b45d830497a317a5a8b3", size = 43518436, upload-time = "2026-01-10T12:26:51.442Z" }, - { url = "https://files.pythonhosted.org/packages/cb/ec/56f328e8fa4ebea453f5bc10c579774dff774a873ff224b3108d53c514f9/polars_runtime_32-1.37.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:47849420859159681e94589daad3a04ff66a2379c116ccd812d043f7ffe0094c", size = 39663939, upload-time = "2026-01-10T12:26:54.664Z" }, - { url = "https://files.pythonhosted.org/packages/4c/b2/f1ea0edba327a92ce0158b7a0e4abe21f541e44c9fb8ec932cc47592ca5c/polars_runtime_32-1.37.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4648ea1e821b9a841b2a562f27bcf54ff1ad21f9c217adcf0f7d0b3c33dc6400", size = 41481348, upload-time = "2026-01-10T12:26:57.598Z" }, - { url = "https://files.pythonhosted.org/packages/3b/21/788a3dd724bb21cf42e2f4daa6510a47787e8b30dd535aa6cae20ea968d0/polars_runtime_32-1.37.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5272b6f1680a3e0d77c9f07cb5a54f307079eb5d519c71aa3c37b9af0ee03a9e", size = 45168069, upload-time = "2026-01-10T12:27:00.98Z" }, - { url = "https://files.pythonhosted.org/packages/8a/73/823d6534a20ebdcec4b7706ab2b3f2cfb8e07571305f4e7381cc22d83e31/polars_runtime_32-1.37.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:73301ef4fe80d8d748085259a4063ac52ff058088daa702e2a75e7d1ab7f14fc", size = 41675645, upload-time = "2026-01-10T12:27:04.334Z" }, - { url = "https://files.pythonhosted.org/packages/30/54/1bacad96dc2b67d33b886a45b249777212782561493718785cb27c7c362a/polars_runtime_32-1.37.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:c60d523d738a7b3660d9abdfaff798f7602488f469d427865965b0bd2e40473a", size = 44737715, upload-time = "2026-01-10T12:27:08.152Z" }, - { url = "https://files.pythonhosted.org/packages/38/e3/aad525d8d89b903fcfa2bd0b4cb66b8a6e83e80b3d1348c5a428092d2983/polars_runtime_32-1.37.0-cp310-abi3-win_amd64.whl", hash = "sha256:f87f76f16e8030d277ecca0c0976aca62ec2b6ba2099ee9c6f75dfc97e7dc1b1", size = 45018403, upload-time = "2026-01-10T12:27:11.292Z" }, - { url = "https://files.pythonhosted.org/packages/0e/4d/ddcaa5f2e18763e02e66d0fd2efca049a42fe96fbeda188e89aeb38dd6fa/polars_runtime_32-1.37.0-cp310-abi3-win_arm64.whl", hash = "sha256:7ffbd9487e3668b0a57519f7ab5ab53ab656086db9f62dceaab41393a07be721", size = 41026243, upload-time = "2026-01-10T12:27:14.563Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a2/e828ea9f845796de02d923edb790e408ca0b560cd68dbd74bb99a1b3c461/polars_runtime_32-1.37.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0b8d4d73ea9977d3731927740e59d814647c5198bdbe359bcf6a8bfce2e79771", size = 43499912, upload-time = "2026-01-12T23:25:51.182Z" }, + { url = "https://files.pythonhosted.org/packages/7e/46/81b71b7aa9e3703ee6e4ef1f69a87e40f58ea7c99212bf49a95071e99c8c/polars_runtime_32-1.37.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:c682bf83f5f352e5e02f5c16c652c48ca40442f07b236f30662b22217320ce76", size = 39695707, upload-time = "2026-01-12T23:25:54.289Z" }, + { url = "https://files.pythonhosted.org/packages/81/2e/20009d1fde7ee919e24040f5c87cb9d0e4f8e3f109b74ba06bc10c02459c/polars_runtime_32-1.37.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fc82b5bbe70ca1a4b764eed1419f6336752d6ba9fc1245388d7f8b12438afa2c", size = 41467034, upload-time = "2026-01-12T23:25:56.925Z" }, + { url = "https://files.pythonhosted.org/packages/eb/21/9b55bea940524324625b1e8fd96233290303eb1bf2c23b54573487bbbc25/polars_runtime_32-1.37.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8362d11ac5193b994c7e9048ffe22ccfb976699cfbf6e128ce0302e06728894", size = 45142711, upload-time = "2026-01-12T23:26:00.817Z" }, + { url = "https://files.pythonhosted.org/packages/8c/25/c5f64461aeccdac6834a89f826d051ccd3b4ce204075e562c87a06ed2619/polars_runtime_32-1.37.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:04f5d5a2f013dca7391b7d8e7672fa6d37573a87f1d45d3dd5f0d9b5565a4b0f", size = 41638564, upload-time = "2026-01-12T23:26:04.186Z" }, + { url = "https://files.pythonhosted.org/packages/35/af/509d3cf6c45e764ccf856beaae26fc34352f16f10f94a7839b1042920a73/polars_runtime_32-1.37.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:fbfde7c0ca8209eeaed546e4a32cca1319189aa61c5f0f9a2b4494262bd0c689", size = 44721136, upload-time = "2026-01-12T23:26:07.088Z" }, + { url = "https://files.pythonhosted.org/packages/af/d1/5c0a83a625f72beef59394bebc57d12637997632a4f9d3ab2ffc2cc62bbf/polars_runtime_32-1.37.1-cp310-abi3-win_amd64.whl", hash = "sha256:da3d3642ae944e18dd17109d2a3036cb94ce50e5495c5023c77b1599d4c861bc", size = 44948288, upload-time = "2026-01-12T23:26:10.214Z" }, + { url = "https://files.pythonhosted.org/packages/10/f3/061bb702465904b6502f7c9081daee34b09ccbaa4f8c94cf43a2a3b6dd6f/polars_runtime_32-1.37.1-cp310-abi3-win_arm64.whl", hash = "sha256:55f2c4847a8d2e267612f564de7b753a4bde3902eaabe7b436a0a4abf75949a0", size = 41001914, upload-time = "2026-01-12T23:26:12.997Z" }, ] [[package]] @@ -5413,109 +5413,123 @@ wheels = [ [[package]] name = "regex" -version = "2025.11.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cc/a9/546676f25e573a4cf00fe8e119b78a37b6a8fe2dc95cda877b30889c9c45/regex-2025.11.3.tar.gz", hash = "sha256:1fedc720f9bb2494ce31a58a1631f9c82df6a09b49c19517ea5cc280b4541e01", size = 414669, upload-time = "2025-11-03T21:34:22.089Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8a/d6/d788d52da01280a30a3f6268aef2aa71043bff359c618fea4c5b536654d5/regex-2025.11.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2b441a4ae2c8049106e8b39973bfbddfb25a179dda2bdb99b0eeb60c40a6a3af", size = 488087, upload-time = "2025-11-03T21:30:47.317Z" }, - { url = "https://files.pythonhosted.org/packages/69/39/abec3bd688ec9bbea3562de0fd764ff802976185f5ff22807bf0a2697992/regex-2025.11.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2fa2eed3f76677777345d2f81ee89f5de2f5745910e805f7af7386a920fa7313", size = 290544, upload-time = "2025-11-03T21:30:49.912Z" }, - { url = "https://files.pythonhosted.org/packages/39/b3/9a231475d5653e60002508f41205c61684bb2ffbf2401351ae2186897fc4/regex-2025.11.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8b4a27eebd684319bdf473d39f1d79eed36bf2cd34bd4465cdb4618d82b3d56", size = 288408, upload-time = "2025-11-03T21:30:51.344Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c5/1929a0491bd5ac2d1539a866768b88965fa8c405f3e16a8cef84313098d6/regex-2025.11.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cf77eac15bd264986c4a2c63353212c095b40f3affb2bc6b4ef80c4776c1a28", size = 781584, upload-time = "2025-11-03T21:30:52.596Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fd/16aa16cf5d497ef727ec966f74164fbe75d6516d3d58ac9aa989bc9cdaad/regex-2025.11.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f9ee819f94c6abfa56ec7b1dbab586f41ebbdc0a57e6524bd5e7f487a878c7", size = 850733, upload-time = "2025-11-03T21:30:53.825Z" }, - { url = "https://files.pythonhosted.org/packages/e6/49/3294b988855a221cb6565189edf5dc43239957427df2d81d4a6b15244f64/regex-2025.11.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:838441333bc90b829406d4a03cb4b8bf7656231b84358628b0406d803931ef32", size = 898691, upload-time = "2025-11-03T21:30:55.575Z" }, - { url = "https://files.pythonhosted.org/packages/14/62/b56d29e70b03666193369bdbdedfdc23946dbe9f81dd78ce262c74d988ab/regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cfe6d3f0c9e3b7e8c0c694b24d25e677776f5ca26dce46fd6b0489f9c8339391", size = 791662, upload-time = "2025-11-03T21:30:57.262Z" }, - { url = "https://files.pythonhosted.org/packages/15/fc/e4c31d061eced63fbf1ce9d853975f912c61a7d406ea14eda2dd355f48e7/regex-2025.11.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2ab815eb8a96379a27c3b6157fcb127c8f59c36f043c1678110cea492868f1d5", size = 782587, upload-time = "2025-11-03T21:30:58.788Z" }, - { url = "https://files.pythonhosted.org/packages/b2/bb/5e30c7394bcf63f0537121c23e796be67b55a8847c3956ae6068f4c70702/regex-2025.11.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:728a9d2d173a65b62bdc380b7932dd8e74ed4295279a8fe1021204ce210803e7", size = 774709, upload-time = "2025-11-03T21:31:00.081Z" }, - { url = "https://files.pythonhosted.org/packages/c5/c4/fce773710af81b0cb37cb4ff0947e75d5d17dee304b93d940b87a67fc2f4/regex-2025.11.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:509dc827f89c15c66a0c216331260d777dd6c81e9a4e4f830e662b0bb296c313", size = 845773, upload-time = "2025-11-03T21:31:01.583Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5e/9466a7ec4b8ec282077095c6eb50a12a389d2e036581134d4919e8ca518c/regex-2025.11.3-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:849202cd789e5f3cf5dcc7822c34b502181b4824a65ff20ce82da5524e45e8e9", size = 836164, upload-time = "2025-11-03T21:31:03.244Z" }, - { url = "https://files.pythonhosted.org/packages/95/18/82980a60e8ed1594eb3c89eb814fb276ef51b9af7caeab1340bfd8564af6/regex-2025.11.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b6f78f98741dcc89607c16b1e9426ee46ce4bf31ac5e6b0d40e81c89f3481ea5", size = 779832, upload-time = "2025-11-03T21:31:04.876Z" }, - { url = "https://files.pythonhosted.org/packages/03/cc/90ab0fdbe6dce064a42015433f9152710139fb04a8b81b4fb57a1cb63ffa/regex-2025.11.3-cp310-cp310-win32.whl", hash = "sha256:149eb0bba95231fb4f6d37c8f760ec9fa6fabf65bab555e128dde5f2475193ec", size = 265802, upload-time = "2025-11-03T21:31:06.581Z" }, - { url = "https://files.pythonhosted.org/packages/34/9d/e9e8493a85f3b1ddc4a5014465f5c2b78c3ea1cbf238dcfde78956378041/regex-2025.11.3-cp310-cp310-win_amd64.whl", hash = "sha256:ee3a83ce492074c35a74cc76cf8235d49e77b757193a5365ff86e3f2f93db9fd", size = 277722, upload-time = "2025-11-03T21:31:08.144Z" }, - { url = "https://files.pythonhosted.org/packages/15/c4/b54b24f553966564506dbf873a3e080aef47b356a3b39b5d5aba992b50db/regex-2025.11.3-cp310-cp310-win_arm64.whl", hash = "sha256:38af559ad934a7b35147716655d4a2f79fcef2d695ddfe06a06ba40ae631fa7e", size = 270289, upload-time = "2025-11-03T21:31:10.267Z" }, - { url = "https://files.pythonhosted.org/packages/f7/90/4fb5056e5f03a7048abd2b11f598d464f0c167de4f2a51aa868c376b8c70/regex-2025.11.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:eadade04221641516fa25139273505a1c19f9bf97589a05bc4cfcd8b4a618031", size = 488081, upload-time = "2025-11-03T21:31:11.946Z" }, - { url = "https://files.pythonhosted.org/packages/85/23/63e481293fac8b069d84fba0299b6666df720d875110efd0338406b5d360/regex-2025.11.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:feff9e54ec0dd3833d659257f5c3f5322a12eee58ffa360984b716f8b92983f4", size = 290554, upload-time = "2025-11-03T21:31:13.387Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9d/b101d0262ea293a0066b4522dfb722eb6a8785a8c3e084396a5f2c431a46/regex-2025.11.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3b30bc921d50365775c09a7ed446359e5c0179e9e2512beec4a60cbcef6ddd50", size = 288407, upload-time = "2025-11-03T21:31:14.809Z" }, - { url = "https://files.pythonhosted.org/packages/0c/64/79241c8209d5b7e00577ec9dca35cd493cc6be35b7d147eda367d6179f6d/regex-2025.11.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f99be08cfead2020c7ca6e396c13543baea32343b7a9a5780c462e323bd8872f", size = 793418, upload-time = "2025-11-03T21:31:16.556Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e2/23cd5d3573901ce8f9757c92ca4db4d09600b865919b6d3e7f69f03b1afd/regex-2025.11.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6dd329a1b61c0ee95ba95385fb0c07ea0d3fe1a21e1349fa2bec272636217118", size = 860448, upload-time = "2025-11-03T21:31:18.12Z" }, - { url = "https://files.pythonhosted.org/packages/2a/4c/aecf31beeaa416d0ae4ecb852148d38db35391aac19c687b5d56aedf3a8b/regex-2025.11.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4c5238d32f3c5269d9e87be0cf096437b7622b6920f5eac4fd202468aaeb34d2", size = 907139, upload-time = "2025-11-03T21:31:20.753Z" }, - { url = "https://files.pythonhosted.org/packages/61/22/b8cb00df7d2b5e0875f60628594d44dba283e951b1ae17c12f99e332cc0a/regex-2025.11.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10483eefbfb0adb18ee9474498c9a32fcf4e594fbca0543bb94c48bac6183e2e", size = 800439, upload-time = "2025-11-03T21:31:22.069Z" }, - { url = "https://files.pythonhosted.org/packages/02/a8/c4b20330a5cdc7a8eb265f9ce593f389a6a88a0c5f280cf4d978f33966bc/regex-2025.11.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:78c2d02bb6e1da0720eedc0bad578049cad3f71050ef8cd065ecc87691bed2b0", size = 782965, upload-time = "2025-11-03T21:31:23.598Z" }, - { url = "https://files.pythonhosted.org/packages/b4/4c/ae3e52988ae74af4b04d2af32fee4e8077f26e51b62ec2d12d246876bea2/regex-2025.11.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e6b49cd2aad93a1790ce9cffb18964f6d3a4b0b3dbdbd5de094b65296fce6e58", size = 854398, upload-time = "2025-11-03T21:31:25.008Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/a8b9cf45874eda14b2e275157ce3b304c87e10fb38d9fc26a6e14eb18227/regex-2025.11.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:885b26aa3ee56433b630502dc3d36ba78d186a00cc535d3806e6bfd9ed3c70ab", size = 845897, upload-time = "2025-11-03T21:31:26.427Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fe/1830eb0236be93d9b145e0bd8ab499f31602fe0999b1f19e99955aa8fe20/regex-2025.11.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ddd76a9f58e6a00f8772e72cff8ebcff78e022be95edf018766707c730593e1e", size = 788906, upload-time = "2025-11-03T21:31:28.078Z" }, - { url = "https://files.pythonhosted.org/packages/66/47/dc2577c1f95f188c1e13e2e69d8825a5ac582ac709942f8a03af42ed6e93/regex-2025.11.3-cp311-cp311-win32.whl", hash = "sha256:3e816cc9aac1cd3cc9a4ec4d860f06d40f994b5c7b4d03b93345f44e08cc68bf", size = 265812, upload-time = "2025-11-03T21:31:29.72Z" }, - { url = "https://files.pythonhosted.org/packages/50/1e/15f08b2f82a9bbb510621ec9042547b54d11e83cb620643ebb54e4eb7d71/regex-2025.11.3-cp311-cp311-win_amd64.whl", hash = "sha256:087511f5c8b7dfbe3a03f5d5ad0c2a33861b1fc387f21f6f60825a44865a385a", size = 277737, upload-time = "2025-11-03T21:31:31.422Z" }, - { url = "https://files.pythonhosted.org/packages/f4/fc/6500eb39f5f76c5e47a398df82e6b535a5e345f839581012a418b16f9cc3/regex-2025.11.3-cp311-cp311-win_arm64.whl", hash = "sha256:1ff0d190c7f68ae7769cd0313fe45820ba07ffebfddfaa89cc1eb70827ba0ddc", size = 270290, upload-time = "2025-11-03T21:31:33.041Z" }, - { url = "https://files.pythonhosted.org/packages/e8/74/18f04cb53e58e3fb107439699bd8375cf5a835eec81084e0bddbd122e4c2/regex-2025.11.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bc8ab71e2e31b16e40868a40a69007bc305e1109bd4658eb6cad007e0bf67c41", size = 489312, upload-time = "2025-11-03T21:31:34.343Z" }, - { url = "https://files.pythonhosted.org/packages/78/3f/37fcdd0d2b1e78909108a876580485ea37c91e1acf66d3bb8e736348f441/regex-2025.11.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:22b29dda7e1f7062a52359fca6e58e548e28c6686f205e780b02ad8ef710de36", size = 291256, upload-time = "2025-11-03T21:31:35.675Z" }, - { url = "https://files.pythonhosted.org/packages/bf/26/0a575f58eb23b7ebd67a45fccbc02ac030b737b896b7e7a909ffe43ffd6a/regex-2025.11.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3a91e4a29938bc1a082cc28fdea44be420bf2bebe2665343029723892eb073e1", size = 288921, upload-time = "2025-11-03T21:31:37.07Z" }, - { url = "https://files.pythonhosted.org/packages/ea/98/6a8dff667d1af907150432cf5abc05a17ccd32c72a3615410d5365ac167a/regex-2025.11.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:08b884f4226602ad40c5d55f52bf91a9df30f513864e0054bad40c0e9cf1afb7", size = 798568, upload-time = "2025-11-03T21:31:38.784Z" }, - { url = "https://files.pythonhosted.org/packages/64/15/92c1db4fa4e12733dd5a526c2dd2b6edcbfe13257e135fc0f6c57f34c173/regex-2025.11.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3e0b11b2b2433d1c39c7c7a30e3f3d0aeeea44c2a8d0bae28f6b95f639927a69", size = 864165, upload-time = "2025-11-03T21:31:40.559Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e7/3ad7da8cdee1ce66c7cd37ab5ab05c463a86ffeb52b1a25fe7bd9293b36c/regex-2025.11.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87eb52a81ef58c7ba4d45c3ca74e12aa4b4e77816f72ca25258a85b3ea96cb48", size = 912182, upload-time = "2025-11-03T21:31:42.002Z" }, - { url = "https://files.pythonhosted.org/packages/84/bd/9ce9f629fcb714ffc2c3faf62b6766ecb7a585e1e885eb699bcf130a5209/regex-2025.11.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a12ab1f5c29b4e93db518f5e3872116b7e9b1646c9f9f426f777b50d44a09e8c", size = 803501, upload-time = "2025-11-03T21:31:43.815Z" }, - { url = "https://files.pythonhosted.org/packages/7c/0f/8dc2e4349d8e877283e6edd6c12bdcebc20f03744e86f197ab6e4492bf08/regex-2025.11.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7521684c8c7c4f6e88e35ec89680ee1aa8358d3f09d27dfbdf62c446f5d4c695", size = 787842, upload-time = "2025-11-03T21:31:45.353Z" }, - { url = "https://files.pythonhosted.org/packages/f9/73/cff02702960bc185164d5619c0c62a2f598a6abff6695d391b096237d4ab/regex-2025.11.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:7fe6e5440584e94cc4b3f5f4d98a25e29ca12dccf8873679a635638349831b98", size = 858519, upload-time = "2025-11-03T21:31:46.814Z" }, - { url = "https://files.pythonhosted.org/packages/61/83/0e8d1ae71e15bc1dc36231c90b46ee35f9d52fab2e226b0e039e7ea9c10a/regex-2025.11.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:8e026094aa12b43f4fd74576714e987803a315c76edb6b098b9809db5de58f74", size = 850611, upload-time = "2025-11-03T21:31:48.289Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f5/70a5cdd781dcfaa12556f2955bf170cd603cb1c96a1827479f8faea2df97/regex-2025.11.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:435bbad13e57eb5606a68443af62bed3556de2f46deb9f7d4237bc2f1c9fb3a0", size = 789759, upload-time = "2025-11-03T21:31:49.759Z" }, - { url = "https://files.pythonhosted.org/packages/59/9b/7c29be7903c318488983e7d97abcf8ebd3830e4c956c4c540005fcfb0462/regex-2025.11.3-cp312-cp312-win32.whl", hash = "sha256:3839967cf4dc4b985e1570fd8d91078f0c519f30491c60f9ac42a8db039be204", size = 266194, upload-time = "2025-11-03T21:31:51.53Z" }, - { url = "https://files.pythonhosted.org/packages/1a/67/3b92df89f179d7c367be654ab5626ae311cb28f7d5c237b6bb976cd5fbbb/regex-2025.11.3-cp312-cp312-win_amd64.whl", hash = "sha256:e721d1b46e25c481dc5ded6f4b3f66c897c58d2e8cfdf77bbced84339108b0b9", size = 277069, upload-time = "2025-11-03T21:31:53.151Z" }, - { url = "https://files.pythonhosted.org/packages/d7/55/85ba4c066fe5094d35b249c3ce8df0ba623cfd35afb22d6764f23a52a1c5/regex-2025.11.3-cp312-cp312-win_arm64.whl", hash = "sha256:64350685ff08b1d3a6fff33f45a9ca183dc1d58bbfe4981604e70ec9801bbc26", size = 270330, upload-time = "2025-11-03T21:31:54.514Z" }, - { url = "https://files.pythonhosted.org/packages/e1/a7/dda24ebd49da46a197436ad96378f17df30ceb40e52e859fc42cac45b850/regex-2025.11.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:c1e448051717a334891f2b9a620fe36776ebf3dd8ec46a0b877c8ae69575feb4", size = 489081, upload-time = "2025-11-03T21:31:55.9Z" }, - { url = "https://files.pythonhosted.org/packages/19/22/af2dc751aacf88089836aa088a1a11c4f21a04707eb1b0478e8e8fb32847/regex-2025.11.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9b5aca4d5dfd7fbfbfbdaf44850fcc7709a01146a797536a8f84952e940cca76", size = 291123, upload-time = "2025-11-03T21:31:57.758Z" }, - { url = "https://files.pythonhosted.org/packages/a3/88/1a3ea5672f4b0a84802ee9891b86743438e7c04eb0b8f8c4e16a42375327/regex-2025.11.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:04d2765516395cf7dda331a244a3282c0f5ae96075f728629287dfa6f76ba70a", size = 288814, upload-time = "2025-11-03T21:32:01.12Z" }, - { url = "https://files.pythonhosted.org/packages/fb/8c/f5987895bf42b8ddeea1b315c9fedcfe07cadee28b9c98cf50d00adcb14d/regex-2025.11.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5d9903ca42bfeec4cebedba8022a7c97ad2aab22e09573ce9976ba01b65e4361", size = 798592, upload-time = "2025-11-03T21:32:03.006Z" }, - { url = "https://files.pythonhosted.org/packages/99/2a/6591ebeede78203fa77ee46a1c36649e02df9eaa77a033d1ccdf2fcd5d4e/regex-2025.11.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:639431bdc89d6429f6721625e8129413980ccd62e9d3f496be618a41d205f160", size = 864122, upload-time = "2025-11-03T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/94/d6/be32a87cf28cf8ed064ff281cfbd49aefd90242a83e4b08b5a86b38e8eb4/regex-2025.11.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f117efad42068f9715677c8523ed2be1518116d1c49b1dd17987716695181efe", size = 912272, upload-time = "2025-11-03T21:32:06.148Z" }, - { url = "https://files.pythonhosted.org/packages/62/11/9bcef2d1445665b180ac7f230406ad80671f0fc2a6ffb93493b5dd8cd64c/regex-2025.11.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4aecb6f461316adf9f1f0f6a4a1a3d79e045f9b71ec76055a791affa3b285850", size = 803497, upload-time = "2025-11-03T21:32:08.162Z" }, - { url = "https://files.pythonhosted.org/packages/e5/a7/da0dc273d57f560399aa16d8a68ae7f9b57679476fc7ace46501d455fe84/regex-2025.11.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3b3a5f320136873cc5561098dfab677eea139521cb9a9e8db98b7e64aef44cbc", size = 787892, upload-time = "2025-11-03T21:32:09.769Z" }, - { url = "https://files.pythonhosted.org/packages/da/4b/732a0c5a9736a0b8d6d720d4945a2f1e6f38f87f48f3173559f53e8d5d82/regex-2025.11.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:75fa6f0056e7efb1f42a1c34e58be24072cb9e61a601340cc1196ae92326a4f9", size = 858462, upload-time = "2025-11-03T21:32:11.769Z" }, - { url = "https://files.pythonhosted.org/packages/0c/f5/a2a03df27dc4c2d0c769220f5110ba8c4084b0bfa9ab0f9b4fcfa3d2b0fc/regex-2025.11.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:dbe6095001465294f13f1adcd3311e50dd84e5a71525f20a10bd16689c61ce0b", size = 850528, upload-time = "2025-11-03T21:32:13.906Z" }, - { url = "https://files.pythonhosted.org/packages/d6/09/e1cd5bee3841c7f6eb37d95ca91cdee7100b8f88b81e41c2ef426910891a/regex-2025.11.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:454d9b4ae7881afbc25015b8627c16d88a597479b9dea82b8c6e7e2e07240dc7", size = 789866, upload-time = "2025-11-03T21:32:15.748Z" }, - { url = "https://files.pythonhosted.org/packages/eb/51/702f5ea74e2a9c13d855a6a85b7f80c30f9e72a95493260193c07f3f8d74/regex-2025.11.3-cp313-cp313-win32.whl", hash = "sha256:28ba4d69171fc6e9896337d4fc63a43660002b7da53fc15ac992abcf3410917c", size = 266189, upload-time = "2025-11-03T21:32:17.493Z" }, - { url = "https://files.pythonhosted.org/packages/8b/00/6e29bb314e271a743170e53649db0fdb8e8ff0b64b4f425f5602f4eb9014/regex-2025.11.3-cp313-cp313-win_amd64.whl", hash = "sha256:bac4200befe50c670c405dc33af26dad5a3b6b255dd6c000d92fe4629f9ed6a5", size = 277054, upload-time = "2025-11-03T21:32:19.042Z" }, - { url = "https://files.pythonhosted.org/packages/25/f1/b156ff9f2ec9ac441710764dda95e4edaf5f36aca48246d1eea3f1fd96ec/regex-2025.11.3-cp313-cp313-win_arm64.whl", hash = "sha256:2292cd5a90dab247f9abe892ac584cb24f0f54680c73fcb4a7493c66c2bf2467", size = 270325, upload-time = "2025-11-03T21:32:21.338Z" }, - { url = "https://files.pythonhosted.org/packages/20/28/fd0c63357caefe5680b8ea052131acbd7f456893b69cc2a90cc3e0dc90d4/regex-2025.11.3-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:1eb1ebf6822b756c723e09f5186473d93236c06c579d2cc0671a722d2ab14281", size = 491984, upload-time = "2025-11-03T21:32:23.466Z" }, - { url = "https://files.pythonhosted.org/packages/df/ec/7014c15626ab46b902b3bcc4b28a7bae46d8f281fc7ea9c95e22fcaaa917/regex-2025.11.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:1e00ec2970aab10dc5db34af535f21fcf32b4a31d99e34963419636e2f85ae39", size = 292673, upload-time = "2025-11-03T21:32:25.034Z" }, - { url = "https://files.pythonhosted.org/packages/23/ab/3b952ff7239f20d05f1f99e9e20188513905f218c81d52fb5e78d2bf7634/regex-2025.11.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a4cb042b615245d5ff9b3794f56be4138b5adc35a4166014d31d1814744148c7", size = 291029, upload-time = "2025-11-03T21:32:26.528Z" }, - { url = "https://files.pythonhosted.org/packages/21/7e/3dc2749fc684f455f162dcafb8a187b559e2614f3826877d3844a131f37b/regex-2025.11.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:44f264d4bf02f3176467d90b294d59bf1db9fe53c141ff772f27a8b456b2a9ed", size = 807437, upload-time = "2025-11-03T21:32:28.363Z" }, - { url = "https://files.pythonhosted.org/packages/1b/0b/d529a85ab349c6a25d1ca783235b6e3eedf187247eab536797021f7126c6/regex-2025.11.3-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7be0277469bf3bd7a34a9c57c1b6a724532a0d235cd0dc4e7f4316f982c28b19", size = 873368, upload-time = "2025-11-03T21:32:30.4Z" }, - { url = "https://files.pythonhosted.org/packages/7d/18/2d868155f8c9e3e9d8f9e10c64e9a9f496bb8f7e037a88a8bed26b435af6/regex-2025.11.3-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0d31e08426ff4b5b650f68839f5af51a92a5b51abd8554a60c2fbc7c71f25d0b", size = 914921, upload-time = "2025-11-03T21:32:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/9d72ff0f354fa783fe2ba913c8734c3b433b86406117a8db4ea2bf1c7a2f/regex-2025.11.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e43586ce5bd28f9f285a6e729466841368c4a0353f6fd08d4ce4630843d3648a", size = 812708, upload-time = "2025-11-03T21:32:34.305Z" }, - { url = "https://files.pythonhosted.org/packages/e7/19/ce4bf7f5575c97f82b6e804ffb5c4e940c62609ab2a0d9538d47a7fdf7d4/regex-2025.11.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:0f9397d561a4c16829d4e6ff75202c1c08b68a3bdbfe29dbfcdb31c9830907c6", size = 795472, upload-time = "2025-11-03T21:32:36.364Z" }, - { url = "https://files.pythonhosted.org/packages/03/86/fd1063a176ffb7b2315f9a1b08d17b18118b28d9df163132615b835a26ee/regex-2025.11.3-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:dd16e78eb18ffdb25ee33a0682d17912e8cc8a770e885aeee95020046128f1ce", size = 868341, upload-time = "2025-11-03T21:32:38.042Z" }, - { url = "https://files.pythonhosted.org/packages/12/43/103fb2e9811205e7386366501bc866a164a0430c79dd59eac886a2822950/regex-2025.11.3-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:ffcca5b9efe948ba0661e9df0fa50d2bc4b097c70b9810212d6b62f05d83b2dd", size = 854666, upload-time = "2025-11-03T21:32:40.079Z" }, - { url = "https://files.pythonhosted.org/packages/7d/22/e392e53f3869b75804762c7c848bd2dd2abf2b70fb0e526f58724638bd35/regex-2025.11.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c56b4d162ca2b43318ac671c65bd4d563e841a694ac70e1a976ac38fcf4ca1d2", size = 799473, upload-time = "2025-11-03T21:32:42.148Z" }, - { url = "https://files.pythonhosted.org/packages/4f/f9/8bd6b656592f925b6845fcbb4d57603a3ac2fb2373344ffa1ed70aa6820a/regex-2025.11.3-cp313-cp313t-win32.whl", hash = "sha256:9ddc42e68114e161e51e272f667d640f97e84a2b9ef14b7477c53aac20c2d59a", size = 268792, upload-time = "2025-11-03T21:32:44.13Z" }, - { url = "https://files.pythonhosted.org/packages/e5/87/0e7d603467775ff65cd2aeabf1b5b50cc1c3708556a8b849a2fa4dd1542b/regex-2025.11.3-cp313-cp313t-win_amd64.whl", hash = "sha256:7a7c7fdf755032ffdd72c77e3d8096bdcb0eb92e89e17571a196f03d88b11b3c", size = 280214, upload-time = "2025-11-03T21:32:45.853Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d0/2afc6f8e94e2b64bfb738a7c2b6387ac1699f09f032d363ed9447fd2bb57/regex-2025.11.3-cp313-cp313t-win_arm64.whl", hash = "sha256:df9eb838c44f570283712e7cff14c16329a9f0fb19ca492d21d4b7528ee6821e", size = 271469, upload-time = "2025-11-03T21:32:48.026Z" }, - { url = "https://files.pythonhosted.org/packages/31/e9/f6e13de7e0983837f7b6d238ad9458800a874bf37c264f7923e63409944c/regex-2025.11.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:9697a52e57576c83139d7c6f213d64485d3df5bf84807c35fa409e6c970801c6", size = 489089, upload-time = "2025-11-03T21:32:50.027Z" }, - { url = "https://files.pythonhosted.org/packages/a3/5c/261f4a262f1fa65141c1b74b255988bd2fa020cc599e53b080667d591cfc/regex-2025.11.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e18bc3f73bd41243c9b38a6d9f2366cd0e0137a9aebe2d8ff76c5b67d4c0a3f4", size = 291059, upload-time = "2025-11-03T21:32:51.682Z" }, - { url = "https://files.pythonhosted.org/packages/8e/57/f14eeb7f072b0e9a5a090d1712741fd8f214ec193dba773cf5410108bb7d/regex-2025.11.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:61a08bcb0ec14ff4e0ed2044aad948d0659604f824cbd50b55e30b0ec6f09c73", size = 288900, upload-time = "2025-11-03T21:32:53.569Z" }, - { url = "https://files.pythonhosted.org/packages/3c/6b/1d650c45e99a9b327586739d926a1cd4e94666b1bd4af90428b36af66dc7/regex-2025.11.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c9c30003b9347c24bcc210958c5d167b9e4f9be786cb380a7d32f14f9b84674f", size = 799010, upload-time = "2025-11-03T21:32:55.222Z" }, - { url = "https://files.pythonhosted.org/packages/99/ee/d66dcbc6b628ce4e3f7f0cbbb84603aa2fc0ffc878babc857726b8aab2e9/regex-2025.11.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4e1e592789704459900728d88d41a46fe3969b82ab62945560a31732ffc19a6d", size = 864893, upload-time = "2025-11-03T21:32:57.239Z" }, - { url = "https://files.pythonhosted.org/packages/bf/2d/f238229f1caba7ac87a6c4153d79947fb0261415827ae0f77c304260c7d3/regex-2025.11.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6538241f45eb5a25aa575dbba1069ad786f68a4f2773a29a2bd3dd1f9de787be", size = 911522, upload-time = "2025-11-03T21:32:59.274Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3d/22a4eaba214a917c80e04f6025d26143690f0419511e0116508e24b11c9b/regex-2025.11.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bce22519c989bb72a7e6b36a199384c53db7722fe669ba891da75907fe3587db", size = 803272, upload-time = "2025-11-03T21:33:01.393Z" }, - { url = "https://files.pythonhosted.org/packages/84/b1/03188f634a409353a84b5ef49754b97dbcc0c0f6fd6c8ede505a8960a0a4/regex-2025.11.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:66d559b21d3640203ab9075797a55165d79017520685fb407b9234d72ab63c62", size = 787958, upload-time = "2025-11-03T21:33:03.379Z" }, - { url = "https://files.pythonhosted.org/packages/99/6a/27d072f7fbf6fadd59c64d210305e1ff865cc3b78b526fd147db768c553b/regex-2025.11.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:669dcfb2e38f9e8c69507bace46f4889e3abbfd9b0c29719202883c0a603598f", size = 859289, upload-time = "2025-11-03T21:33:05.374Z" }, - { url = "https://files.pythonhosted.org/packages/9a/70/1b3878f648e0b6abe023172dacb02157e685564853cc363d9961bcccde4e/regex-2025.11.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:32f74f35ff0f25a5021373ac61442edcb150731fbaa28286bbc8bb1582c89d02", size = 850026, upload-time = "2025-11-03T21:33:07.131Z" }, - { url = "https://files.pythonhosted.org/packages/dd/d5/68e25559b526b8baab8e66839304ede68ff6727237a47727d240006bd0ff/regex-2025.11.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e6c7a21dffba883234baefe91bc3388e629779582038f75d2a5be918e250f0ed", size = 789499, upload-time = "2025-11-03T21:33:09.141Z" }, - { url = "https://files.pythonhosted.org/packages/fc/df/43971264857140a350910d4e33df725e8c94dd9dee8d2e4729fa0d63d49e/regex-2025.11.3-cp314-cp314-win32.whl", hash = "sha256:795ea137b1d809eb6836b43748b12634291c0ed55ad50a7d72d21edf1cd565c4", size = 271604, upload-time = "2025-11-03T21:33:10.9Z" }, - { url = "https://files.pythonhosted.org/packages/01/6f/9711b57dc6894a55faf80a4c1b5aa4f8649805cb9c7aef46f7d27e2b9206/regex-2025.11.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f95fbaa0ee1610ec0fc6b26668e9917a582ba80c52cc6d9ada15e30aa9ab9ad", size = 280320, upload-time = "2025-11-03T21:33:12.572Z" }, - { url = "https://files.pythonhosted.org/packages/f1/7e/f6eaa207d4377481f5e1775cdeb5a443b5a59b392d0065f3417d31d80f87/regex-2025.11.3-cp314-cp314-win_arm64.whl", hash = "sha256:dfec44d532be4c07088c3de2876130ff0fbeeacaa89a137decbbb5f665855a0f", size = 273372, upload-time = "2025-11-03T21:33:14.219Z" }, - { url = "https://files.pythonhosted.org/packages/c3/06/49b198550ee0f5e4184271cee87ba4dfd9692c91ec55289e6282f0f86ccf/regex-2025.11.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ba0d8a5d7f04f73ee7d01d974d47c5834f8a1b0224390e4fe7c12a3a92a78ecc", size = 491985, upload-time = "2025-11-03T21:33:16.555Z" }, - { url = "https://files.pythonhosted.org/packages/ce/bf/abdafade008f0b1c9da10d934034cb670432d6cf6cbe38bbb53a1cfd6cf8/regex-2025.11.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:442d86cf1cfe4faabf97db7d901ef58347efd004934da045c745e7b5bd57ac49", size = 292669, upload-time = "2025-11-03T21:33:18.32Z" }, - { url = "https://files.pythonhosted.org/packages/f9/ef/0c357bb8edbd2ad8e273fcb9e1761bc37b8acbc6e1be050bebd6475f19c1/regex-2025.11.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fd0a5e563c756de210bb964789b5abe4f114dacae9104a47e1a649b910361536", size = 291030, upload-time = "2025-11-03T21:33:20.048Z" }, - { url = "https://files.pythonhosted.org/packages/79/06/edbb67257596649b8fb088d6aeacbcb248ac195714b18a65e018bf4c0b50/regex-2025.11.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf3490bcbb985a1ae97b2ce9ad1c0f06a852d5b19dde9b07bdf25bf224248c95", size = 807674, upload-time = "2025-11-03T21:33:21.797Z" }, - { url = "https://files.pythonhosted.org/packages/f4/d9/ad4deccfce0ea336296bd087f1a191543bb99ee1c53093dcd4c64d951d00/regex-2025.11.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3809988f0a8b8c9dcc0f92478d6501fac7200b9ec56aecf0ec21f4a2ec4b6009", size = 873451, upload-time = "2025-11-03T21:33:23.741Z" }, - { url = "https://files.pythonhosted.org/packages/13/75/a55a4724c56ef13e3e04acaab29df26582f6978c000ac9cd6810ad1f341f/regex-2025.11.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f4ff94e58e84aedb9c9fce66d4ef9f27a190285b451420f297c9a09f2b9abee9", size = 914980, upload-time = "2025-11-03T21:33:25.999Z" }, - { url = "https://files.pythonhosted.org/packages/67/1e/a1657ee15bd9116f70d4a530c736983eed997b361e20ecd8f5ca3759d5c5/regex-2025.11.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eb542fd347ce61e1321b0a6b945d5701528dca0cd9759c2e3bb8bd57e47964d", size = 812852, upload-time = "2025-11-03T21:33:27.852Z" }, - { url = "https://files.pythonhosted.org/packages/b8/6f/f7516dde5506a588a561d296b2d0044839de06035bb486b326065b4c101e/regex-2025.11.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d6c2d5919075a1f2e413c00b056ea0c2f065b3f5fe83c3d07d325ab92dce51d6", size = 795566, upload-time = "2025-11-03T21:33:32.364Z" }, - { url = "https://files.pythonhosted.org/packages/d9/dd/3d10b9e170cc16fb34cb2cef91513cf3df65f440b3366030631b2984a264/regex-2025.11.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3f8bf11a4827cc7ce5a53d4ef6cddd5ad25595d3c1435ef08f76825851343154", size = 868463, upload-time = "2025-11-03T21:33:34.459Z" }, - { url = "https://files.pythonhosted.org/packages/f5/8e/935e6beff1695aa9085ff83195daccd72acc82c81793df480f34569330de/regex-2025.11.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:22c12d837298651e5550ac1d964e4ff57c3f56965fc1812c90c9fb2028eaf267", size = 854694, upload-time = "2025-11-03T21:33:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/92/12/10650181a040978b2f5720a6a74d44f841371a3d984c2083fc1752e4acf6/regex-2025.11.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ba394a3dda9ad41c7c780f60f6e4a70988741415ae96f6d1bf6c239cf01379", size = 799691, upload-time = "2025-11-03T21:33:39.079Z" }, - { url = "https://files.pythonhosted.org/packages/67/90/8f37138181c9a7690e7e4cb388debbd389342db3c7381d636d2875940752/regex-2025.11.3-cp314-cp314t-win32.whl", hash = "sha256:4bf146dca15cdd53224a1bf46d628bd7590e4a07fbb69e720d561aea43a32b38", size = 274583, upload-time = "2025-11-03T21:33:41.302Z" }, - { url = "https://files.pythonhosted.org/packages/8f/cd/867f5ec442d56beb56f5f854f40abcfc75e11d10b11fdb1869dd39c63aaf/regex-2025.11.3-cp314-cp314t-win_amd64.whl", hash = "sha256:adad1a1bcf1c9e76346e091d22d23ac54ef28e1365117d99521631078dfec9de", size = 284286, upload-time = "2025-11-03T21:33:43.324Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/32c0c4610cbc070362bf1d2e4ea86d1ea29014d400a6d6c2486fcfd57766/regex-2025.11.3-cp314-cp314t-win_arm64.whl", hash = "sha256:c54f768482cef41e219720013cd05933b6f971d9562544d691c68699bf2b6801", size = 274741, upload-time = "2025-11-03T21:33:45.557Z" }, +version = "2026.1.15" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/86/07d5056945f9ec4590b518171c4254a5925832eb727b56d3c38a7476f316/regex-2026.1.15.tar.gz", hash = "sha256:164759aa25575cbc0651bef59a0b18353e54300d79ace8084c818ad8ac72b7d5", size = 414811, upload-time = "2026-01-14T23:18:02.775Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/d2/e6ee96b7dff201a83f650241c52db8e5bd080967cb93211f57aa448dc9d6/regex-2026.1.15-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4e3dd93c8f9abe8aa4b6c652016da9a3afa190df5ad822907efe6b206c09896e", size = 488166, upload-time = "2026-01-14T23:13:46.408Z" }, + { url = "https://files.pythonhosted.org/packages/23/8a/819e9ce14c9f87af026d0690901b3931f3101160833e5d4c8061fa3a1b67/regex-2026.1.15-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:97499ff7862e868b1977107873dd1a06e151467129159a6ffd07b66706ba3a9f", size = 290632, upload-time = "2026-01-14T23:13:48.688Z" }, + { url = "https://files.pythonhosted.org/packages/d5/c3/23dfe15af25d1d45b07dfd4caa6003ad710dcdcb4c4b279909bdfe7a2de8/regex-2026.1.15-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0bda75ebcac38d884240914c6c43d8ab5fb82e74cde6da94b43b17c411aa4c2b", size = 288500, upload-time = "2026-01-14T23:13:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/c6/31/1adc33e2f717df30d2f4d973f8776d2ba6ecf939301efab29fca57505c95/regex-2026.1.15-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7dcc02368585334f5bc81fc73a2a6a0bbade60e7d83da21cead622faf408f32c", size = 781670, upload-time = "2026-01-14T23:13:52.453Z" }, + { url = "https://files.pythonhosted.org/packages/23/ce/21a8a22d13bc4adcb927c27b840c948f15fc973e21ed2346c1bd0eae22dc/regex-2026.1.15-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:693b465171707bbe882a7a05de5e866f33c76aa449750bee94a8d90463533cc9", size = 850820, upload-time = "2026-01-14T23:13:54.894Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/3eeacdf587a4705a44484cd0b30e9230a0e602811fb3e2cc32268c70d509/regex-2026.1.15-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b0d190e6f013ea938623a58706d1469a62103fb2a241ce2873a9906e0386582c", size = 898777, upload-time = "2026-01-14T23:13:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/79/a9/1898a077e2965c35fc22796488141a22676eed2d73701e37c73ad7c0b459/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ff818702440a5878a81886f127b80127f5d50563753a28211482867f8318106", size = 791750, upload-time = "2026-01-14T23:13:58.527Z" }, + { url = "https://files.pythonhosted.org/packages/4c/84/e31f9d149a178889b3817212827f5e0e8c827a049ff31b4b381e76b26e2d/regex-2026.1.15-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f052d1be37ef35a54e394de66136e30fa1191fab64f71fc06ac7bc98c9a84618", size = 782674, upload-time = "2026-01-14T23:13:59.874Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ff/adf60063db24532add6a1676943754a5654dcac8237af024ede38244fd12/regex-2026.1.15-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6bfc31a37fd1592f0c4fc4bfc674b5c42e52efe45b4b7a6a14f334cca4bcebe4", size = 767906, upload-time = "2026-01-14T23:14:01.298Z" }, + { url = "https://files.pythonhosted.org/packages/af/3e/e6a216cee1e2780fec11afe7fc47b6f3925d7264e8149c607ac389fd9b1a/regex-2026.1.15-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d6ce5ae80066b319ae3bc62fd55a557c9491baa5efd0d355f0de08c4ba54e79", size = 774798, upload-time = "2026-01-14T23:14:02.715Z" }, + { url = "https://files.pythonhosted.org/packages/0f/98/23a4a8378a9208514ed3efc7e7850c27fa01e00ed8557c958df0335edc4a/regex-2026.1.15-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1704d204bd42b6bb80167df0e4554f35c255b579ba99616def38f69e14a5ccb9", size = 845861, upload-time = "2026-01-14T23:14:04.824Z" }, + { url = "https://files.pythonhosted.org/packages/f8/57/d7605a9d53bd07421a8785d349cd29677fe660e13674fa4c6cbd624ae354/regex-2026.1.15-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:e3174a5ed4171570dc8318afada56373aa9289eb6dc0d96cceb48e7358b0e220", size = 755648, upload-time = "2026-01-14T23:14:06.371Z" }, + { url = "https://files.pythonhosted.org/packages/6f/76/6f2e24aa192da1e299cc1101674a60579d3912391867ce0b946ba83e2194/regex-2026.1.15-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:87adf5bd6d72e3e17c9cb59ac4096b1faaf84b7eb3037a5ffa61c4b4370f0f13", size = 836250, upload-time = "2026-01-14T23:14:08.343Z" }, + { url = "https://files.pythonhosted.org/packages/11/3a/1f2a1d29453299a7858eab7759045fc3d9d1b429b088dec2dc85b6fa16a2/regex-2026.1.15-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e85dc94595f4d766bd7d872a9de5ede1ca8d3063f3bdf1e2c725f5eb411159e3", size = 779919, upload-time = "2026-01-14T23:14:09.954Z" }, + { url = "https://files.pythonhosted.org/packages/c0/67/eab9bc955c9dcc58e9b222c801e39cff7ca0b04261792a2149166ce7e792/regex-2026.1.15-cp310-cp310-win32.whl", hash = "sha256:21ca32c28c30d5d65fc9886ff576fc9b59bbca08933e844fa2363e530f4c8218", size = 265888, upload-time = "2026-01-14T23:14:11.35Z" }, + { url = "https://files.pythonhosted.org/packages/1d/62/31d16ae24e1f8803bddb0885508acecaec997fcdcde9c243787103119ae4/regex-2026.1.15-cp310-cp310-win_amd64.whl", hash = "sha256:3038a62fc7d6e5547b8915a3d927a0fbeef84cdbe0b1deb8c99bbd4a8961b52a", size = 277830, upload-time = "2026-01-14T23:14:12.908Z" }, + { url = "https://files.pythonhosted.org/packages/e5/36/5d9972bccd6417ecd5a8be319cebfd80b296875e7f116c37fb2a2deecebf/regex-2026.1.15-cp310-cp310-win_arm64.whl", hash = "sha256:505831646c945e3e63552cc1b1b9b514f0e93232972a2d5bedbcc32f15bc82e3", size = 270376, upload-time = "2026-01-14T23:14:14.782Z" }, + { url = "https://files.pythonhosted.org/packages/d0/c9/0c80c96eab96948363d270143138d671d5731c3a692b417629bf3492a9d6/regex-2026.1.15-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1ae6020fb311f68d753b7efa9d4b9a5d47a5d6466ea0d5e3b5a471a960ea6e4a", size = 488168, upload-time = "2026-01-14T23:14:16.129Z" }, + { url = "https://files.pythonhosted.org/packages/17/f0/271c92f5389a552494c429e5cc38d76d1322eb142fb5db3c8ccc47751468/regex-2026.1.15-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eddf73f41225942c1f994914742afa53dc0d01a6e20fe14b878a1b1edc74151f", size = 290636, upload-time = "2026-01-14T23:14:17.715Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f9/5f1fd077d106ca5655a0f9ff8f25a1ab55b92128b5713a91ed7134ff688e/regex-2026.1.15-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e8cd52557603f5c66a548f69421310886b28b7066853089e1a71ee710e1cdc1", size = 288496, upload-time = "2026-01-14T23:14:19.326Z" }, + { url = "https://files.pythonhosted.org/packages/b5/e1/8f43b03a4968c748858ec77f746c286d81f896c2e437ccf050ebc5d3128c/regex-2026.1.15-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5170907244b14303edc5978f522f16c974f32d3aa92109fabc2af52411c9433b", size = 793503, upload-time = "2026-01-14T23:14:20.922Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/a39a5e8edc5377a46a7c875c2f9a626ed3338cb3bb06931be461c3e1a34a/regex-2026.1.15-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2748c1ec0663580b4510bd89941a31560b4b439a0b428b49472a3d9944d11cd8", size = 860535, upload-time = "2026-01-14T23:14:22.405Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1c/9dce667a32a9477f7a2869c1c767dc00727284a9fa3ff5c09a5c6c03575e/regex-2026.1.15-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2f2775843ca49360508d080eaa87f94fa248e2c946bbcd963bb3aae14f333413", size = 907225, upload-time = "2026-01-14T23:14:23.897Z" }, + { url = "https://files.pythonhosted.org/packages/a4/3c/87ca0a02736d16b6262921425e84b48984e77d8e4e572c9072ce96e66c30/regex-2026.1.15-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d9ea2604370efc9a174c1b5dcc81784fb040044232150f7f33756049edfc9026", size = 800526, upload-time = "2026-01-14T23:14:26.039Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/647d5715aeea7c87bdcbd2f578f47b415f55c24e361e639fe8c0cc88878f/regex-2026.1.15-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0dcd31594264029b57bf16f37fd7248a70b3b764ed9e0839a8f271b2d22c0785", size = 773446, upload-time = "2026-01-14T23:14:28.109Z" }, + { url = "https://files.pythonhosted.org/packages/af/89/bf22cac25cb4ba0fe6bff52ebedbb65b77a179052a9d6037136ae93f42f4/regex-2026.1.15-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c08c1f3e34338256732bd6938747daa3c0d5b251e04b6e43b5813e94d503076e", size = 783051, upload-time = "2026-01-14T23:14:29.929Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f4/6ed03e71dca6348a5188363a34f5e26ffd5db1404780288ff0d79513bce4/regex-2026.1.15-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:e43a55f378df1e7a4fa3547c88d9a5a9b7113f653a66821bcea4718fe6c58763", size = 854485, upload-time = "2026-01-14T23:14:31.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/8e8560bd78caded8eb137e3e47612430a05b9a772caf60876435192d670a/regex-2026.1.15-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f82110ab962a541737bd0ce87978d4c658f06e7591ba899192e2712a517badbb", size = 762195, upload-time = "2026-01-14T23:14:32.802Z" }, + { url = "https://files.pythonhosted.org/packages/38/6b/61fc710f9aa8dfcd764fe27d37edfaa023b1a23305a0d84fccd5adb346ea/regex-2026.1.15-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:27618391db7bdaf87ac6c92b31e8f0dfb83a9de0075855152b720140bda177a2", size = 845986, upload-time = "2026-01-14T23:14:34.898Z" }, + { url = "https://files.pythonhosted.org/packages/fd/2e/fbee4cb93f9d686901a7ca8d94285b80405e8c34fe4107f63ffcbfb56379/regex-2026.1.15-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bfb0d6be01fbae8d6655c8ca21b3b72458606c4aec9bbc932db758d47aba6db1", size = 788992, upload-time = "2026-01-14T23:14:37.116Z" }, + { url = "https://files.pythonhosted.org/packages/ed/14/3076348f3f586de64b1ab75a3fbabdaab7684af7f308ad43be7ef1849e55/regex-2026.1.15-cp311-cp311-win32.whl", hash = "sha256:b10e42a6de0e32559a92f2f8dc908478cc0fa02838d7dbe764c44dca3fa13569", size = 265893, upload-time = "2026-01-14T23:14:38.426Z" }, + { url = "https://files.pythonhosted.org/packages/0f/19/772cf8b5fc803f5c89ba85d8b1870a1ca580dc482aa030383a9289c82e44/regex-2026.1.15-cp311-cp311-win_amd64.whl", hash = "sha256:e9bf3f0bbdb56633c07d7116ae60a576f846efdd86a8848f8d62b749e1209ca7", size = 277840, upload-time = "2026-01-14T23:14:39.785Z" }, + { url = "https://files.pythonhosted.org/packages/78/84/d05f61142709474da3c0853222d91086d3e1372bcdab516c6fd8d80f3297/regex-2026.1.15-cp311-cp311-win_arm64.whl", hash = "sha256:41aef6f953283291c4e4e6850607bd71502be67779586a61472beacb315c97ec", size = 270374, upload-time = "2026-01-14T23:14:41.592Z" }, + { url = "https://files.pythonhosted.org/packages/92/81/10d8cf43c807d0326efe874c1b79f22bfb0fb226027b0b19ebc26d301408/regex-2026.1.15-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4c8fcc5793dde01641a35905d6731ee1548f02b956815f8f1cab89e515a5bdf1", size = 489398, upload-time = "2026-01-14T23:14:43.741Z" }, + { url = "https://files.pythonhosted.org/packages/90/b0/7c2a74e74ef2a7c32de724658a69a862880e3e4155cba992ba04d1c70400/regex-2026.1.15-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bfd876041a956e6a90ad7cdb3f6a630c07d491280bfeed4544053cd434901681", size = 291339, upload-time = "2026-01-14T23:14:45.183Z" }, + { url = "https://files.pythonhosted.org/packages/19/4d/16d0773d0c818417f4cc20aa0da90064b966d22cd62a8c46765b5bd2d643/regex-2026.1.15-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9250d087bc92b7d4899ccd5539a1b2334e44eee85d848c4c1aef8e221d3f8c8f", size = 289003, upload-time = "2026-01-14T23:14:47.25Z" }, + { url = "https://files.pythonhosted.org/packages/c6/e4/1fc4599450c9f0863d9406e944592d968b8d6dfd0d552a7d569e43bceada/regex-2026.1.15-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8a154cf6537ebbc110e24dabe53095e714245c272da9c1be05734bdad4a61aa", size = 798656, upload-time = "2026-01-14T23:14:48.77Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e6/59650d73a73fa8a60b3a590545bfcf1172b4384a7df2e7fe7b9aab4e2da9/regex-2026.1.15-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8050ba2e3ea1d8731a549e83c18d2f0999fbc99a5f6bd06b4c91449f55291804", size = 864252, upload-time = "2026-01-14T23:14:50.528Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ab/1d0f4d50a1638849a97d731364c9a80fa304fec46325e48330c170ee8e80/regex-2026.1.15-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf065240704cb8951cc04972cf107063917022511273e0969bdb34fc173456c", size = 912268, upload-time = "2026-01-14T23:14:52.952Z" }, + { url = "https://files.pythonhosted.org/packages/dd/df/0d722c030c82faa1d331d1921ee268a4e8fb55ca8b9042c9341c352f17fa/regex-2026.1.15-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c32bef3e7aeee75746748643667668ef941d28b003bfc89994ecf09a10f7a1b5", size = 803589, upload-time = "2026-01-14T23:14:55.182Z" }, + { url = "https://files.pythonhosted.org/packages/66/23/33289beba7ccb8b805c6610a8913d0131f834928afc555b241caabd422a9/regex-2026.1.15-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d5eaa4a4c5b1906bd0d2508d68927f15b81821f85092e06f1a34a4254b0e1af3", size = 775700, upload-time = "2026-01-14T23:14:56.707Z" }, + { url = "https://files.pythonhosted.org/packages/e7/65/bf3a42fa6897a0d3afa81acb25c42f4b71c274f698ceabd75523259f6688/regex-2026.1.15-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:86c1077a3cc60d453d4084d5b9649065f3bf1184e22992bd322e1f081d3117fb", size = 787928, upload-time = "2026-01-14T23:14:58.312Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f5/13bf65864fc314f68cdd6d8ca94adcab064d4d39dbd0b10fef29a9da48fc/regex-2026.1.15-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:2b091aefc05c78d286657cd4db95f2e6313375ff65dcf085e42e4c04d9c8d410", size = 858607, upload-time = "2026-01-14T23:15:00.657Z" }, + { url = "https://files.pythonhosted.org/packages/a3/31/040e589834d7a439ee43fb0e1e902bc81bd58a5ba81acffe586bb3321d35/regex-2026.1.15-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:57e7d17f59f9ebfa9667e6e5a1c0127b96b87cb9cede8335482451ed00788ba4", size = 763729, upload-time = "2026-01-14T23:15:02.248Z" }, + { url = "https://files.pythonhosted.org/packages/9b/84/6921e8129687a427edf25a34a5594b588b6d88f491320b9de5b6339a4fcb/regex-2026.1.15-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:c6c4dcdfff2c08509faa15d36ba7e5ef5fcfab25f1e8f85a0c8f45bc3a30725d", size = 850697, upload-time = "2026-01-14T23:15:03.878Z" }, + { url = "https://files.pythonhosted.org/packages/8a/87/3d06143d4b128f4229158f2de5de6c8f2485170c7221e61bf381313314b2/regex-2026.1.15-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cf8ff04c642716a7f2048713ddc6278c5fd41faa3b9cab12607c7abecd012c22", size = 789849, upload-time = "2026-01-14T23:15:06.102Z" }, + { url = "https://files.pythonhosted.org/packages/77/69/c50a63842b6bd48850ebc7ab22d46e7a2a32d824ad6c605b218441814639/regex-2026.1.15-cp312-cp312-win32.whl", hash = "sha256:82345326b1d8d56afbe41d881fdf62f1926d7264b2fc1537f99ae5da9aad7913", size = 266279, upload-time = "2026-01-14T23:15:07.678Z" }, + { url = "https://files.pythonhosted.org/packages/f2/36/39d0b29d087e2b11fd8191e15e81cce1b635fcc845297c67f11d0d19274d/regex-2026.1.15-cp312-cp312-win_amd64.whl", hash = "sha256:4def140aa6156bc64ee9912383d4038f3fdd18fee03a6f222abd4de6357ce42a", size = 277166, upload-time = "2026-01-14T23:15:09.257Z" }, + { url = "https://files.pythonhosted.org/packages/28/32/5b8e476a12262748851fa8ab1b0be540360692325975b094e594dfebbb52/regex-2026.1.15-cp312-cp312-win_arm64.whl", hash = "sha256:c6c565d9a6e1a8d783c1948937ffc377dd5771e83bd56de8317c450a954d2056", size = 270415, upload-time = "2026-01-14T23:15:10.743Z" }, + { url = "https://files.pythonhosted.org/packages/f8/2e/6870bb16e982669b674cce3ee9ff2d1d46ab80528ee6bcc20fb2292efb60/regex-2026.1.15-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e69d0deeb977ffe7ed3d2e4439360089f9c3f217ada608f0f88ebd67afb6385e", size = 489164, upload-time = "2026-01-14T23:15:13.962Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/9774542e203849b0286badf67199970a44ebdb0cc5fb739f06e47ada72f8/regex-2026.1.15-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3601ffb5375de85a16f407854d11cca8fe3f5febbe3ac78fb2866bb220c74d10", size = 291218, upload-time = "2026-01-14T23:15:15.647Z" }, + { url = "https://files.pythonhosted.org/packages/b2/87/b0cda79f22b8dee05f774922a214da109f9a4c0eca5da2c9d72d77ea062c/regex-2026.1.15-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4c5ef43b5c2d4114eb8ea424bb8c9cec01d5d17f242af88b2448f5ee81caadbc", size = 288895, upload-time = "2026-01-14T23:15:17.788Z" }, + { url = "https://files.pythonhosted.org/packages/3b/6a/0041f0a2170d32be01ab981d6346c83a8934277d82c780d60b127331f264/regex-2026.1.15-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:968c14d4f03e10b2fd960f1d5168c1f0ac969381d3c1fcc973bc45fb06346599", size = 798680, upload-time = "2026-01-14T23:15:19.342Z" }, + { url = "https://files.pythonhosted.org/packages/58/de/30e1cfcdbe3e891324aa7568b7c968771f82190df5524fabc1138cb2d45a/regex-2026.1.15-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56a5595d0f892f214609c9f76b41b7428bed439d98dc961efafdd1354d42baae", size = 864210, upload-time = "2026-01-14T23:15:22.005Z" }, + { url = "https://files.pythonhosted.org/packages/64/44/4db2f5c5ca0ccd40ff052ae7b1e9731352fcdad946c2b812285a7505ca75/regex-2026.1.15-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf650f26087363434c4e560011f8e4e738f6f3e029b85d4904c50135b86cfa5", size = 912358, upload-time = "2026-01-14T23:15:24.569Z" }, + { url = "https://files.pythonhosted.org/packages/79/b6/e6a5665d43a7c42467138c8a2549be432bad22cbd206f5ec87162de74bd7/regex-2026.1.15-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18388a62989c72ac24de75f1449d0fb0b04dfccd0a1a7c1c43af5eb503d890f6", size = 803583, upload-time = "2026-01-14T23:15:26.526Z" }, + { url = "https://files.pythonhosted.org/packages/e7/53/7cd478222169d85d74d7437e74750005e993f52f335f7c04ff7adfda3310/regex-2026.1.15-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6d220a2517f5893f55daac983bfa9fe998a7dbcaee4f5d27a88500f8b7873788", size = 775782, upload-time = "2026-01-14T23:15:29.352Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b5/75f9a9ee4b03a7c009fe60500fe550b45df94f0955ca29af16333ef557c5/regex-2026.1.15-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c9c08c2fbc6120e70abff5d7f28ffb4d969e14294fb2143b4b5c7d20e46d1714", size = 787978, upload-time = "2026-01-14T23:15:31.295Z" }, + { url = "https://files.pythonhosted.org/packages/72/b3/79821c826245bbe9ccbb54f6eadb7879c722fd3e0248c17bfc90bf54e123/regex-2026.1.15-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7ef7d5d4bd49ec7364315167a4134a015f61e8266c6d446fc116a9ac4456e10d", size = 858550, upload-time = "2026-01-14T23:15:33.558Z" }, + { url = "https://files.pythonhosted.org/packages/4a/85/2ab5f77a1c465745bfbfcb3ad63178a58337ae8d5274315e2cc623a822fa/regex-2026.1.15-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e42844ad64194fa08d5ccb75fe6a459b9b08e6d7296bd704460168d58a388f3", size = 763747, upload-time = "2026-01-14T23:15:35.206Z" }, + { url = "https://files.pythonhosted.org/packages/6d/84/c27df502d4bfe2873a3e3a7cf1bdb2b9cc10284d1a44797cf38bed790470/regex-2026.1.15-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:cfecdaa4b19f9ca534746eb3b55a5195d5c95b88cac32a205e981ec0a22b7d31", size = 850615, upload-time = "2026-01-14T23:15:37.523Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b7/658a9782fb253680aa8ecb5ccbb51f69e088ed48142c46d9f0c99b46c575/regex-2026.1.15-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:08df9722d9b87834a3d701f3fca570b2be115654dbfd30179f30ab2f39d606d3", size = 789951, upload-time = "2026-01-14T23:15:39.582Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2a/5928af114441e059f15b2f63e188bd00c6529b3051c974ade7444b85fcda/regex-2026.1.15-cp313-cp313-win32.whl", hash = "sha256:d426616dae0967ca225ab12c22274eb816558f2f99ccb4a1d52ca92e8baf180f", size = 266275, upload-time = "2026-01-14T23:15:42.108Z" }, + { url = "https://files.pythonhosted.org/packages/4f/16/5bfbb89e435897bff28cf0352a992ca719d9e55ebf8b629203c96b6ce4f7/regex-2026.1.15-cp313-cp313-win_amd64.whl", hash = "sha256:febd38857b09867d3ed3f4f1af7d241c5c50362e25ef43034995b77a50df494e", size = 277145, upload-time = "2026-01-14T23:15:44.244Z" }, + { url = "https://files.pythonhosted.org/packages/56/c1/a09ff7392ef4233296e821aec5f78c51be5e91ffde0d163059e50fd75835/regex-2026.1.15-cp313-cp313-win_arm64.whl", hash = "sha256:8e32f7896f83774f91499d239e24cebfadbc07639c1494bb7213983842348337", size = 270411, upload-time = "2026-01-14T23:15:45.858Z" }, + { url = "https://files.pythonhosted.org/packages/3c/38/0cfd5a78e5c6db00e6782fdae70458f89850ce95baa5e8694ab91d89744f/regex-2026.1.15-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:ec94c04149b6a7b8120f9f44565722c7ae31b7a6d2275569d2eefa76b83da3be", size = 492068, upload-time = "2026-01-14T23:15:47.616Z" }, + { url = "https://files.pythonhosted.org/packages/50/72/6c86acff16cb7c959c4355826bbf06aad670682d07c8f3998d9ef4fee7cd/regex-2026.1.15-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40c86d8046915bb9aeb15d3f3f15b6fd500b8ea4485b30e1bbc799dab3fe29f8", size = 292756, upload-time = "2026-01-14T23:15:49.307Z" }, + { url = "https://files.pythonhosted.org/packages/4e/58/df7fb69eadfe76526ddfce28abdc0af09ffe65f20c2c90932e89d705153f/regex-2026.1.15-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:726ea4e727aba21643205edad8f2187ec682d3305d790f73b7a51c7587b64bdd", size = 291114, upload-time = "2026-01-14T23:15:51.484Z" }, + { url = "https://files.pythonhosted.org/packages/ed/6c/a4011cd1cf96b90d2cdc7e156f91efbd26531e822a7fbb82a43c1016678e/regex-2026.1.15-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1cb740d044aff31898804e7bf1181cc72c03d11dfd19932b9911ffc19a79070a", size = 807524, upload-time = "2026-01-14T23:15:53.102Z" }, + { url = "https://files.pythonhosted.org/packages/1d/25/a53ffb73183f69c3e9f4355c4922b76d2840aee160af6af5fac229b6201d/regex-2026.1.15-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05d75a668e9ea16f832390d22131fe1e8acc8389a694c8febc3e340b0f810b93", size = 873455, upload-time = "2026-01-14T23:15:54.956Z" }, + { url = "https://files.pythonhosted.org/packages/66/0b/8b47fc2e8f97d9b4a851736f3890a5f786443aa8901061c55f24c955f45b/regex-2026.1.15-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d991483606f3dbec93287b9f35596f41aa2e92b7c2ebbb935b63f409e243c9af", size = 915007, upload-time = "2026-01-14T23:15:57.041Z" }, + { url = "https://files.pythonhosted.org/packages/c2/fa/97de0d681e6d26fabe71968dbee06dd52819e9a22fdce5dac7256c31ed84/regex-2026.1.15-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:194312a14819d3e44628a44ed6fea6898fdbecb0550089d84c403475138d0a09", size = 812794, upload-time = "2026-01-14T23:15:58.916Z" }, + { url = "https://files.pythonhosted.org/packages/22/38/e752f94e860d429654aa2b1c51880bff8dfe8f084268258adf9151cf1f53/regex-2026.1.15-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fe2fda4110a3d0bc163c2e0664be44657431440722c5c5315c65155cab92f9e5", size = 781159, upload-time = "2026-01-14T23:16:00.817Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/d739ffaef33c378fc888302a018d7f81080393d96c476b058b8c64fd2b0d/regex-2026.1.15-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:124dc36c85d34ef2d9164da41a53c1c8c122cfb1f6e1ec377a1f27ee81deb794", size = 795558, upload-time = "2026-01-14T23:16:03.267Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c4/542876f9a0ac576100fc73e9c75b779f5c31e3527576cfc9cb3009dcc58a/regex-2026.1.15-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1774cd1981cd212506a23a14dba7fdeaee259f5deba2df6229966d9911e767a", size = 868427, upload-time = "2026-01-14T23:16:05.646Z" }, + { url = "https://files.pythonhosted.org/packages/fc/0f/d5655bea5b22069e32ae85a947aa564912f23758e112cdb74212848a1a1b/regex-2026.1.15-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b5f7d8d2867152cdb625e72a530d2ccb48a3d199159144cbdd63870882fb6f80", size = 769939, upload-time = "2026-01-14T23:16:07.542Z" }, + { url = "https://files.pythonhosted.org/packages/20/06/7e18a4fa9d326daeda46d471a44ef94201c46eaa26dbbb780b5d92cbfdda/regex-2026.1.15-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:492534a0ab925d1db998defc3c302dae3616a2fc3fe2e08db1472348f096ddf2", size = 854753, upload-time = "2026-01-14T23:16:10.395Z" }, + { url = "https://files.pythonhosted.org/packages/3b/67/dc8946ef3965e166f558ef3b47f492bc364e96a265eb4a2bb3ca765c8e46/regex-2026.1.15-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c661fc820cfb33e166bf2450d3dadbda47c8d8981898adb9b6fe24e5e582ba60", size = 799559, upload-time = "2026-01-14T23:16:12.347Z" }, + { url = "https://files.pythonhosted.org/packages/a5/61/1bba81ff6d50c86c65d9fd84ce9699dd106438ee4cdb105bf60374ee8412/regex-2026.1.15-cp313-cp313t-win32.whl", hash = "sha256:99ad739c3686085e614bf77a508e26954ff1b8f14da0e3765ff7abbf7799f952", size = 268879, upload-time = "2026-01-14T23:16:14.049Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5e/cef7d4c5fb0ea3ac5c775fd37db5747f7378b29526cc83f572198924ff47/regex-2026.1.15-cp313-cp313t-win_amd64.whl", hash = "sha256:32655d17905e7ff8ba5c764c43cb124e34a9245e45b83c22e81041e1071aee10", size = 280317, upload-time = "2026-01-14T23:16:15.718Z" }, + { url = "https://files.pythonhosted.org/packages/b4/52/4317f7a5988544e34ab57b4bde0f04944c4786128c933fb09825924d3e82/regex-2026.1.15-cp313-cp313t-win_arm64.whl", hash = "sha256:b2a13dd6a95e95a489ca242319d18fc02e07ceb28fa9ad146385194d95b3c829", size = 271551, upload-time = "2026-01-14T23:16:17.533Z" }, + { url = "https://files.pythonhosted.org/packages/52/0a/47fa888ec7cbbc7d62c5f2a6a888878e76169170ead271a35239edd8f0e8/regex-2026.1.15-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:d920392a6b1f353f4aa54328c867fec3320fa50657e25f64abf17af054fc97ac", size = 489170, upload-time = "2026-01-14T23:16:19.835Z" }, + { url = "https://files.pythonhosted.org/packages/ac/c4/d000e9b7296c15737c9301708e9e7fbdea009f8e93541b6b43bdb8219646/regex-2026.1.15-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b5a28980a926fa810dbbed059547b02783952e2efd9c636412345232ddb87ff6", size = 291146, upload-time = "2026-01-14T23:16:21.541Z" }, + { url = "https://files.pythonhosted.org/packages/f9/b6/921cc61982e538682bdf3bdf5b2c6ab6b34368da1f8e98a6c1ddc503c9cf/regex-2026.1.15-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:621f73a07595d83f28952d7bd1e91e9d1ed7625fb7af0064d3516674ec93a2a2", size = 288986, upload-time = "2026-01-14T23:16:23.381Z" }, + { url = "https://files.pythonhosted.org/packages/ca/33/eb7383dde0bbc93f4fb9d03453aab97e18ad4024ac7e26cef8d1f0a2cff0/regex-2026.1.15-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3d7d92495f47567a9b1669c51fc8d6d809821849063d168121ef801bbc213846", size = 799098, upload-time = "2026-01-14T23:16:25.088Z" }, + { url = "https://files.pythonhosted.org/packages/27/56/b664dccae898fc8d8b4c23accd853f723bde0f026c747b6f6262b688029c/regex-2026.1.15-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8dd16fba2758db7a3780a051f245539c4451ca20910f5a5e6ea1c08d06d4a76b", size = 864980, upload-time = "2026-01-14T23:16:27.297Z" }, + { url = "https://files.pythonhosted.org/packages/16/40/0999e064a170eddd237bae9ccfcd8f28b3aa98a38bf727a086425542a4fc/regex-2026.1.15-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1e1808471fbe44c1a63e5f577a1d5f02fe5d66031dcbdf12f093ffc1305a858e", size = 911607, upload-time = "2026-01-14T23:16:29.235Z" }, + { url = "https://files.pythonhosted.org/packages/07/78/c77f644b68ab054e5a674fb4da40ff7bffb2c88df58afa82dbf86573092d/regex-2026.1.15-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0751a26ad39d4f2ade8fe16c59b2bf5cb19eb3d2cd543e709e583d559bd9efde", size = 803358, upload-time = "2026-01-14T23:16:31.369Z" }, + { url = "https://files.pythonhosted.org/packages/27/31/d4292ea8566eaa551fafc07797961c5963cf5235c797cc2ae19b85dfd04d/regex-2026.1.15-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0f0c7684c7f9ca241344ff95a1de964f257a5251968484270e91c25a755532c5", size = 775833, upload-time = "2026-01-14T23:16:33.141Z" }, + { url = "https://files.pythonhosted.org/packages/ce/b2/cff3bf2fea4133aa6fb0d1e370b37544d18c8350a2fa118c7e11d1db0e14/regex-2026.1.15-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:74f45d170a21df41508cb67165456538425185baaf686281fa210d7e729abc34", size = 788045, upload-time = "2026-01-14T23:16:35.005Z" }, + { url = "https://files.pythonhosted.org/packages/8d/99/2cb9b69045372ec877b6f5124bda4eb4253bc58b8fe5848c973f752bc52c/regex-2026.1.15-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f1862739a1ffb50615c0fde6bae6569b5efbe08d98e59ce009f68a336f64da75", size = 859374, upload-time = "2026-01-14T23:16:36.919Z" }, + { url = "https://files.pythonhosted.org/packages/09/16/710b0a5abe8e077b1729a562d2f297224ad079f3a66dce46844c193416c8/regex-2026.1.15-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:453078802f1b9e2b7303fb79222c054cb18e76f7bdc220f7530fdc85d319f99e", size = 763940, upload-time = "2026-01-14T23:16:38.685Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/7585c8e744e40eb3d32f119191969b91de04c073fca98ec14299041f6e7e/regex-2026.1.15-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:a30a68e89e5a218b8b23a52292924c1f4b245cb0c68d1cce9aec9bbda6e2c160", size = 850112, upload-time = "2026-01-14T23:16:40.646Z" }, + { url = "https://files.pythonhosted.org/packages/af/d6/43e1dd85df86c49a347aa57c1f69d12c652c7b60e37ec162e3096194a278/regex-2026.1.15-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9479cae874c81bf610d72b85bb681a94c95722c127b55445285fb0e2c82db8e1", size = 789586, upload-time = "2026-01-14T23:16:42.799Z" }, + { url = "https://files.pythonhosted.org/packages/93/38/77142422f631e013f316aaae83234c629555729a9fbc952b8a63ac91462a/regex-2026.1.15-cp314-cp314-win32.whl", hash = "sha256:d639a750223132afbfb8f429c60d9d318aeba03281a5f1ab49f877456448dcf1", size = 271691, upload-time = "2026-01-14T23:16:44.671Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a9/ab16b4649524ca9e05213c1cdbb7faa85cc2aa90a0230d2f796cbaf22736/regex-2026.1.15-cp314-cp314-win_amd64.whl", hash = "sha256:4161d87f85fa831e31469bfd82c186923070fc970b9de75339b68f0c75b51903", size = 280422, upload-time = "2026-01-14T23:16:46.607Z" }, + { url = "https://files.pythonhosted.org/packages/be/2a/20fd057bf3521cb4791f69f869635f73e0aaf2b9ad2d260f728144f9047c/regex-2026.1.15-cp314-cp314-win_arm64.whl", hash = "sha256:91c5036ebb62663a6b3999bdd2e559fd8456d17e2b485bf509784cd31a8b1705", size = 273467, upload-time = "2026-01-14T23:16:48.967Z" }, + { url = "https://files.pythonhosted.org/packages/ad/77/0b1e81857060b92b9cad239104c46507dd481b3ff1fa79f8e7f865aae38a/regex-2026.1.15-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ee6854c9000a10938c79238de2379bea30c82e4925a371711af45387df35cab8", size = 492073, upload-time = "2026-01-14T23:16:51.154Z" }, + { url = "https://files.pythonhosted.org/packages/70/f3/f8302b0c208b22c1e4f423147e1913fd475ddd6230565b299925353de644/regex-2026.1.15-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2c2b80399a422348ce5de4fe40c418d6299a0fa2803dd61dc0b1a2f28e280fcf", size = 292757, upload-time = "2026-01-14T23:16:53.08Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f0/ef55de2460f3b4a6da9d9e7daacd0cb79d4ef75c64a2af316e68447f0df0/regex-2026.1.15-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:dca3582bca82596609959ac39e12b7dad98385b4fefccb1151b937383cec547d", size = 291122, upload-time = "2026-01-14T23:16:55.383Z" }, + { url = "https://files.pythonhosted.org/packages/cf/55/bb8ccbacabbc3a11d863ee62a9f18b160a83084ea95cdfc5d207bfc3dd75/regex-2026.1.15-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef71d476caa6692eea743ae5ea23cde3260677f70122c4d258ca952e5c2d4e84", size = 807761, upload-time = "2026-01-14T23:16:57.251Z" }, + { url = "https://files.pythonhosted.org/packages/8f/84/f75d937f17f81e55679a0509e86176e29caa7298c38bd1db7ce9c0bf6075/regex-2026.1.15-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c243da3436354f4af6c3058a3f81a97d47ea52c9bd874b52fd30274853a1d5df", size = 873538, upload-time = "2026-01-14T23:16:59.349Z" }, + { url = "https://files.pythonhosted.org/packages/b8/d9/0da86327df70349aa8d86390da91171bd3ca4f0e7c1d1d453a9c10344da3/regex-2026.1.15-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8355ad842a7c7e9e5e55653eade3b7d1885ba86f124dd8ab1f722f9be6627434", size = 915066, upload-time = "2026-01-14T23:17:01.607Z" }, + { url = "https://files.pythonhosted.org/packages/2a/5e/f660fb23fc77baa2a61aa1f1fe3a4eea2bbb8a286ddec148030672e18834/regex-2026.1.15-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f192a831d9575271a22d804ff1a5355355723f94f31d9eef25f0d45a152fdc1a", size = 812938, upload-time = "2026-01-14T23:17:04.366Z" }, + { url = "https://files.pythonhosted.org/packages/69/33/a47a29bfecebbbfd1e5cd3f26b28020a97e4820f1c5148e66e3b7d4b4992/regex-2026.1.15-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:166551807ec20d47ceaeec380081f843e88c8949780cd42c40f18d16168bed10", size = 781314, upload-time = "2026-01-14T23:17:06.378Z" }, + { url = "https://files.pythonhosted.org/packages/65/ec/7ec2bbfd4c3f4e494a24dec4c6943a668e2030426b1b8b949a6462d2c17b/regex-2026.1.15-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:f9ca1cbdc0fbfe5e6e6f8221ef2309988db5bcede52443aeaee9a4ad555e0dac", size = 795652, upload-time = "2026-01-14T23:17:08.521Z" }, + { url = "https://files.pythonhosted.org/packages/46/79/a5d8651ae131fe27d7c521ad300aa7f1c7be1dbeee4d446498af5411b8a9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b30bcbd1e1221783c721483953d9e4f3ab9c5d165aa709693d3f3946747b1aea", size = 868550, upload-time = "2026-01-14T23:17:10.573Z" }, + { url = "https://files.pythonhosted.org/packages/06/b7/25635d2809664b79f183070786a5552dd4e627e5aedb0065f4e3cf8ee37d/regex-2026.1.15-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2a8d7b50c34578d0d3bf7ad58cde9652b7d683691876f83aedc002862a35dc5e", size = 769981, upload-time = "2026-01-14T23:17:12.871Z" }, + { url = "https://files.pythonhosted.org/packages/16/8b/fc3fcbb2393dcfa4a6c5ffad92dc498e842df4581ea9d14309fcd3c55fb9/regex-2026.1.15-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9d787e3310c6a6425eb346be4ff2ccf6eece63017916fd77fe8328c57be83521", size = 854780, upload-time = "2026-01-14T23:17:14.837Z" }, + { url = "https://files.pythonhosted.org/packages/d0/38/dde117c76c624713c8a2842530be9c93ca8b606c0f6102d86e8cd1ce8bea/regex-2026.1.15-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:619843841e220adca114118533a574a9cd183ed8a28b85627d2844c500a2b0db", size = 799778, upload-time = "2026-01-14T23:17:17.369Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0d/3a6cfa9ae99606afb612d8fb7a66b245a9d5ff0f29bb347c8a30b6ad561b/regex-2026.1.15-cp314-cp314t-win32.whl", hash = "sha256:e90b8db97f6f2c97eb045b51a6b2c5ed69cedd8392459e0642d4199b94fabd7e", size = 274667, upload-time = "2026-01-14T23:17:19.301Z" }, + { url = "https://files.pythonhosted.org/packages/5b/b2/297293bb0742fd06b8d8e2572db41a855cdf1cae0bf009b1cb74fe07e196/regex-2026.1.15-cp314-cp314t-win_amd64.whl", hash = "sha256:5ef19071f4ac9f0834793af85bd04a920b4407715624e40cb7a0631a11137cdf", size = 284386, upload-time = "2026-01-14T23:17:21.231Z" }, + { url = "https://files.pythonhosted.org/packages/95/e4/a3b9480c78cf8ee86626cb06f8d931d74d775897d44201ccb813097ae697/regex-2026.1.15-cp314-cp314t-win_arm64.whl", hash = "sha256:ca89c5e596fc05b015f27561b3793dc2fa0917ea0d7507eebb448efd35274a70", size = 274837, upload-time = "2026-01-14T23:17:23.146Z" }, ] [[package]] @@ -6661,28 +6675,28 @@ wheels = [ [[package]] name = "uv" -version = "0.9.24" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0b/7f/6692596de7775b3059a55539aed2eec16a0642a2d6d3510baa5878287ce4/uv-0.9.24.tar.gz", hash = "sha256:d59d31c25fc530c68db9164174efac511a25fc882cec49cd48f75a18e7ebd6d5", size = 3852673, upload-time = "2026-01-09T22:34:31.635Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/51/10bb9541c40a5b4672527c357997a30fdf38b75e7bbaad0c37ed70889efa/uv-0.9.24-py3-none-linux_armv6l.whl", hash = "sha256:75a000f529ec92235b10fb5e16ca41f23f46c643308fd6c5b0d7b73ca056c5b9", size = 21395664, upload-time = "2026-01-09T22:34:05.887Z" }, - { url = "https://files.pythonhosted.org/packages/ec/dd/d7df524cb764ebc652e0c8bf9abe55fc34391adc2e4ab1d47375222b38a9/uv-0.9.24-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:207c8a2d4c4d55589feb63b4be74f6ff6ab92fa81b14a6515007ccec5a868ae0", size = 20547988, upload-time = "2026-01-09T22:34:16.21Z" }, - { url = "https://files.pythonhosted.org/packages/49/e4/7ca5e7eaed4b2b9d407aa5aeeb8f71cace7db77f30a63139bbbfdfe4770c/uv-0.9.24-py3-none-macosx_11_0_arm64.whl", hash = "sha256:44c0b8a78724e4cfa8e9c0266023c70fc792d0b39a5da17f5f847af2b530796b", size = 19033208, upload-time = "2026-01-09T22:33:50.91Z" }, - { url = "https://files.pythonhosted.org/packages/27/05/b7bab99541056537747bfdc55fdc97a4ba998e2b53cf855411ef176c412b/uv-0.9.24-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:841ede01d6dcf1676a21dce05f3647ba171c1d92768a03e8b8b6b7354b34a6d2", size = 20872212, upload-time = "2026-01-09T22:33:58.007Z" }, - { url = "https://files.pythonhosted.org/packages/d3/93/3a69cf481175766ee6018afb281666de12ccc04367d20a41dc070be8b422/uv-0.9.24-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:69531d9a8772afb2dff68fef2469f666e4f8a0132b2109e36541c423415835da", size = 21017966, upload-time = "2026-01-09T22:34:29.354Z" }, - { url = "https://files.pythonhosted.org/packages/17/40/7aec2d428e57a3ec992efc49bbc71e4a0ceece5a726751c661ddc3f41315/uv-0.9.24-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6720c9939cca7daff3cccc35dd896bbe139d7d463c62cba8dbbc474ff8eb93d1", size = 21943358, upload-time = "2026-01-09T22:34:08.63Z" }, - { url = "https://files.pythonhosted.org/packages/c8/f4/2aa5b275aa8e5edb659036e94bae13ae294377384cf2a93a8d742a38050f/uv-0.9.24-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:d7d1333d9c21088c89cb284ef29fdf48dc2015fe993174a823a3e7c991db90f9", size = 23672949, upload-time = "2026-01-09T22:34:03.113Z" }, - { url = "https://files.pythonhosted.org/packages/8e/24/2589bed4b39394c799472f841e0580318a8b7e69ef103a0ab50cf1c39dff/uv-0.9.24-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b610d89d6025000d08cd9bd458c6e264003a0ecfdaa8e4eba28938130cd1837", size = 23270210, upload-time = "2026-01-09T22:34:13.94Z" }, - { url = "https://files.pythonhosted.org/packages/80/3a/034494492a1ad1f95371c6fd735e4b7d180b8c1712c88b0f32a34d6352fd/uv-0.9.24-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:38c59e18fe5fa42f7baeb4f08c94914cee6d87ff8faa6fc95c994dbc0de26c90", size = 22282247, upload-time = "2026-01-09T22:33:53.362Z" }, - { url = "https://files.pythonhosted.org/packages/be/0e/d8ab2c4fa6c9410a8a37fa6608d460b0126cee2efed9eecf516cdec72a1a/uv-0.9.24-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:009cc82cdfc48add6ec13a0c4ffbb788ae2cab53573b4218069ca626721a404b", size = 22348801, upload-time = "2026-01-09T22:34:00.46Z" }, - { url = "https://files.pythonhosted.org/packages/50/fa/7217764e4936d6fda1944d956452bf94f790ae8a02cb3e5aa496d23fcb25/uv-0.9.24-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:1914d33e526167dc202ec4a59119c68467b31f7c71dcf8b1077571d091ca3e7c", size = 21000825, upload-time = "2026-01-09T22:34:21.811Z" }, - { url = "https://files.pythonhosted.org/packages/94/8f/533db58a36895142b0c11eedf8bfe11c4724fb37deaa417bfb0c689d40b8/uv-0.9.24-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:aafe7dd9b633672054cf27f1a8e4127506324631f1af5edd051728f4f8085351", size = 22149066, upload-time = "2026-01-09T22:33:45.722Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c7/e6eccd96341a548f0405bffdf55e7f30b5c0757cd1b8f7578e0972a66002/uv-0.9.24-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:63a0a46693098cf8446e41bd5d9ce7d3bc9b775a63fe0c8405ab6ee328424d46", size = 20993489, upload-time = "2026-01-09T22:34:27.007Z" }, - { url = "https://files.pythonhosted.org/packages/46/07/32d852d2d40c003b52601c44202c9d9e655c485fae5d84e42f326814b0be/uv-0.9.24-py3-none-musllinux_1_1_i686.whl", hash = "sha256:15d3955bfb03a7b78aaf5afb639cedefdf0fc35ff844c92e3fe6e8700b94b84f", size = 21400775, upload-time = "2026-01-09T22:34:24.278Z" }, - { url = "https://files.pythonhosted.org/packages/b0/58/f8e94226126011ba2e2e9d59c6190dc7fe9e61fa7ef4ca720d7226c1482b/uv-0.9.24-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:488a07e59fb417bf86de5630197223b7a0223229e626afc124c26827db78cff8", size = 22554194, upload-time = "2026-01-09T22:34:18.504Z" }, - { url = "https://files.pythonhosted.org/packages/da/8e/b540c304039a6561ba8e9a673009cfe1451f989d2269fe40690901ddb233/uv-0.9.24-py3-none-win32.whl", hash = "sha256:68a3186074c03876ee06b68730d5ff69a430296760d917ebbbb8e3fb54fb4091", size = 20203184, upload-time = "2026-01-09T22:34:11.02Z" }, - { url = "https://files.pythonhosted.org/packages/16/59/dba7c5feec1f694183578435eaae0d759b8c459c5e4f91237a166841a116/uv-0.9.24-py3-none-win_amd64.whl", hash = "sha256:8cd626306b415491f839b1a9100da6795c82c44d4cf278dd7ace7a774af89df4", size = 22294050, upload-time = "2026-01-09T22:33:48.228Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ef/e58fb288bafb5a8b5d4994e73fa6e062e408680e5a20d0427d5f4f66d8b1/uv-0.9.24-py3-none-win_arm64.whl", hash = "sha256:8d3c0fec7aa17f936a5b258816e856647b21f978a81bcfb2dc8caf2892a4965e", size = 20620004, upload-time = "2026-01-09T22:33:55.62Z" }, +version = "0.9.25" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/b3/c2a6afd3d8f8f9f5d9c65fcdff1b80fb5bdaba21c8b0e99dd196e71d311f/uv-0.9.25.tar.gz", hash = "sha256:8625de8f40e7b669713e293ab4f7044bca9aa7f7c739f17dc1fd0cb765e69f28", size = 3863318, upload-time = "2026-01-13T23:20:16.141Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/13/e1/9284199aed638643a4feadf8b3283c1d43b3c3adcbdac367f26a8f5e398f/uv-0.9.25-py3-none-linux_armv6l.whl", hash = "sha256:db51f37b3f6c94f4371d8e26ee8adeb9b1b1447c5fda8cc47608694e49ea5031", size = 21479938, upload-time = "2026-01-13T23:21:13.011Z" }, + { url = "https://files.pythonhosted.org/packages/6d/5c/79dc42e1abf0afc021823c688ff04e4283f9e72d20ca4af0027aa7ed29df/uv-0.9.25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e47a9da2ddd33b5e7efb8068a24de24e24fd0d88a99e0c4a7e2328424783eab8", size = 20681034, upload-time = "2026-01-13T23:20:19.269Z" }, + { url = "https://files.pythonhosted.org/packages/7c/0b/997f279db671fe4b1cf87ad252719c1b7c47a9546efd6c2594b5648ea983/uv-0.9.25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:79af8c9b885b507a82087e45161a4bda7f2382682867dc95f7e6d22514ac844d", size = 19096089, upload-time = "2026-01-13T23:20:55.021Z" }, + { url = "https://files.pythonhosted.org/packages/d5/60/a7682177fe76501b403d464b4fee25c1ee4089fe56caf7cb87c2e6741375/uv-0.9.25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:6ca6bdd3fe4b1730d1e3d10a4ce23b269915a60712379d3318ecea9a4ff861fd", size = 20848810, upload-time = "2026-01-13T23:20:13.916Z" }, + { url = "https://files.pythonhosted.org/packages/4c/c1/01d5df4cbec33da51fc85868f129562cbd1488290465107c03bed90d8ca4/uv-0.9.25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9d993b9c590ac76f805e17441125d67c7774b1ba05340dc987d3de01852226b6", size = 21095071, upload-time = "2026-01-13T23:20:44.488Z" }, + { url = "https://files.pythonhosted.org/packages/6d/fe/f7cd2f02b0e0974dd95f732efd12bd36a3e8419d53f4d1d49744d2e3d979/uv-0.9.25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c4b72881d0c66ad77844451dbdbcada87242c0d39c6bfd0f89ac30b917a3cfc3", size = 22070541, upload-time = "2026-01-13T23:21:16.936Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e6/ef53b6d69b303eca6aa56ad97eb322f6cc5b9571c403e4e64313f1ccfb81/uv-0.9.25-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ac0dfb6191e91723a69be533102f98ffa5739cba57c3dfc5f78940c27cf0d7e8", size = 23663768, upload-time = "2026-01-13T23:20:29.808Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f8/f0e01ddfc62cb4b8ec5c6d94e46fc77035c0cd77865d7958144caadf8ad9/uv-0.9.25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41ae0f2df7c931b72949345134070efa919174321c5bd403954db960fa4c2d7d", size = 23235860, upload-time = "2026-01-13T23:20:58.724Z" }, + { url = "https://files.pythonhosted.org/packages/6a/56/905257af2c63ffaec9add9cce5d34f851f418d42e6f4e73fee18adecd499/uv-0.9.25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3bf02fcea14b8bec42b9c04094cc5b527c2cd53b606c06e7bdabfbd943b4512c", size = 22236426, upload-time = "2026-01-13T23:20:40.995Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ce/909feee469647b7929967397dcb1b6b317cfca07dc3fc0699b3cab700daf/uv-0.9.25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:642f993d8c74ecd52b192d5f3168433c4efa81b8bb19c5ac97c25f27a44557cb", size = 22294538, upload-time = "2026-01-13T23:21:09.521Z" }, + { url = "https://files.pythonhosted.org/packages/82/be/ac7cd3c45c6baf0d5181133d3bda13f843f76799809374095b6fc7122a96/uv-0.9.25-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:564b5db5e148670fdbcfd962ee8292c0764c1be0c765f63b620600a3c81087d1", size = 20963345, upload-time = "2026-01-13T23:20:25.706Z" }, + { url = "https://files.pythonhosted.org/packages/19/fd/7b6191cef8da4ad451209dde083123b1ac9d10d6c2c1554a1de64aa41ad8/uv-0.9.25-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:991cfb872ef3bc0cc5e88f4d3f68adf181218a3a57860f523ff25279e4cf6657", size = 22205573, upload-time = "2026-01-13T23:20:33.611Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/8d6809df5e5ddf862f963fbfc8b2a25c286dc36724e50c7536e429d718be/uv-0.9.25-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:e1b4ab678c6816fe41e3090777393cf57a0f4ef122f99e9447d789ab83863a78", size = 21036715, upload-time = "2026-01-13T23:20:51.413Z" }, + { url = "https://files.pythonhosted.org/packages/bb/78/e3cb00bf90a359fa8106e2446bad07e49922b41e096e4d3b335b0065117a/uv-0.9.25-py3-none-musllinux_1_1_i686.whl", hash = "sha256:aa7db0ab689c3df34bdd46f83d2281d268161677ccd204804a87172150a654ef", size = 21505379, upload-time = "2026-01-13T23:21:06.045Z" }, + { url = "https://files.pythonhosted.org/packages/86/36/07f69f45878175d2907110858e5c6631a1b712420d229012296c1462b133/uv-0.9.25-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:a658e47e54f11dac9b2751fba4ad966a15db46c386497cf51c1c02f656508358", size = 22520308, upload-time = "2026-01-13T23:20:09.704Z" }, + { url = "https://files.pythonhosted.org/packages/1d/1b/2d457ee7e2dd35fc22ae6f656bb45b781b33083d4f0a40901b9ae59e0b10/uv-0.9.25-py3-none-win32.whl", hash = "sha256:4df14479f034f6d4dca9f52230f912772f56ceead3354c7b186a34927c22188a", size = 20263705, upload-time = "2026-01-13T23:20:47.814Z" }, + { url = "https://files.pythonhosted.org/packages/5c/0b/05ad2dc53dab2c8aa2e112ef1f9227a7b625ba3507bedd7b31153d73aa5f/uv-0.9.25-py3-none-win_amd64.whl", hash = "sha256:001629fbc2a955c35f373311591c6952be010a935b0bc6244dc61da108e4593d", size = 22311694, upload-time = "2026-01-13T23:21:02.562Z" }, + { url = "https://files.pythonhosted.org/packages/54/4e/99788924989082356d6aa79d8bfdba1a2e495efaeae346fd8fec83d3f078/uv-0.9.25-py3-none-win_arm64.whl", hash = "sha256:ea26319abf9f5e302af0d230c0f13f02591313e5ffadac34931f963ef4d7833d", size = 20645549, upload-time = "2026-01-13T23:20:37.201Z" }, ] [[package]]