diff --git a/.github/workflows/python-check-coverage.py b/.github/workflows/python-check-coverage.py index c8f96cd0ab..9f48cbdcbf 100644 --- a/.github/workflows/python-check-coverage.py +++ b/.github/workflows/python-check-coverage.py @@ -30,9 +30,11 @@ from dataclasses import dataclass # ============================================================================= ENFORCED_MODULES: set[str] = { "packages.azure-ai.agent_framework_azure_ai", + "packages.core.agent_framework", + "packages.core.agent_framework._workflows", + "packages.purview.agent_framework_purview", # Add more modules here as coverage improves: - # "packages.core.agent_framework", - # "packages.core.agent_framework._workflows", + # "packages.azure-ai-search.agent_framework_azure_ai_search", # "packages.anthropic.agent_framework_anthropic", } diff --git a/dotnet/Directory.Packages.props b/dotnet/Directory.Packages.props index 98c7376aaf..d0227c1dbe 100644 --- a/dotnet/Directory.Packages.props +++ b/dotnet/Directory.Packages.props @@ -33,18 +33,18 @@ - + - + - - + + @@ -61,9 +61,9 @@ - - - + + + @@ -71,11 +71,11 @@ - + - + diff --git a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs index aa18fdd286..426b40f1f5 100644 --- a/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs +++ b/dotnet/samples/GettingStarted/AgentWithOpenAI/Agent_OpenAI_Step02_Reasoning/Program.cs @@ -5,7 +5,6 @@ using Microsoft.Agents.AI; using Microsoft.Extensions.AI; using OpenAI; -using OpenAI.Responses; var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? throw new InvalidOperationException("OPENAI_API_KEY is not set."); var model = Environment.GetEnvironmentVariable("OPENAI_MODEL") ?? "gpt-5"; @@ -15,14 +14,10 @@ var client = new OpenAIClient(apiKey) .AsIChatClient().AsBuilder() .ConfigureOptions(o => { - o.RawRepresentationFactory = _ => new CreateResponseOptions() + o.Reasoning = new() { - ReasoningOptions = new() - { - ReasoningEffortLevel = ResponseReasoningEffortLevel.Medium, - // Verbosity requires OpenAI verified Organization - ReasoningSummaryVerbosity = ResponseReasoningSummaryVerbosity.Detailed - } + Effort = ReasoningEffort.Medium, + Output = ReasoningOutput.Full, }; }).Build(); diff --git a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs index 8f38378626..a266aed278 100644 --- a/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs +++ b/dotnet/samples/GettingStarted/FoundryAgents/FoundryAgents_Step10_UsingImages/Program.cs @@ -21,7 +21,7 @@ AIAgent agent = await aiProjectClient.CreateAIAgentAsync(name: VisionName, model ChatMessage message = new(ChatRole.User, [ new TextContent("What do you see in this image?"), - new DataContent(File.ReadAllBytes("assets/walkway.jpg"), "image/jpeg") + await DataContent.LoadFromAsync("assets/walkway.jpg"), ]); AgentSession session = await agent.CreateSessionAsync(); diff --git a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs index 82ca49705e..1c12f96796 100644 --- a/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.AzureAI/AzureAIProjectChatClientExtensions.cs @@ -283,8 +283,13 @@ public static partial class AzureAIProjectChatClientExtensions TextOptions = new() { TextFormat = ToOpenAIResponseTextFormat(options.ChatOptions?.ResponseFormat, options.ChatOptions) } }; - // Attempt to capture breaking glass options from the raw representation factory that match the agent definition. - if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions) + // Map reasoning options from the abstraction-level ChatOptions.Reasoning, + // falling back to extracting from the raw representation factory for breaking glass scenarios. + if (options.ChatOptions?.Reasoning is { } reasoning) + { + agentDefinition.ReasoningOptions = ToResponseReasoningOptions(reasoning); + } + else if (options.ChatOptions?.RawRepresentationFactory?.Invoke(new NoOpChatClient()) is CreateResponseOptions respCreationOptions) { agentDefinition.ReasoningOptions = respCreationOptions.ReasoningOptions; } @@ -770,6 +775,36 @@ public static partial class AzureAIProjectChatClientExtensions } return name; } + + private static ResponseReasoningOptions? ToResponseReasoningOptions(ReasoningOptions reasoning) + { + ResponseReasoningEffortLevel? effortLevel = reasoning.Effort switch + { + ReasoningEffort.Low => ResponseReasoningEffortLevel.Low, + ReasoningEffort.Medium => ResponseReasoningEffortLevel.Medium, + ReasoningEffort.High => ResponseReasoningEffortLevel.High, + ReasoningEffort.ExtraHigh => ResponseReasoningEffortLevel.High, + _ => null, + }; + + ResponseReasoningSummaryVerbosity? summary = reasoning.Output switch + { + ReasoningOutput.Summary => ResponseReasoningSummaryVerbosity.Concise, + ReasoningOutput.Full => ResponseReasoningSummaryVerbosity.Detailed, + _ => null, + }; + + if (effortLevel is null && summary is null) + { + return null; + } + + return new ResponseReasoningOptions + { + ReasoningEffortLevel = effortLevel, + ReasoningSummaryVerbosity = summary, + }; + } } [JsonSerializable(typeof(JsonElement))] diff --git a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs index 0556430636..cdec40dd96 100644 --- a/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs +++ b/dotnet/src/Microsoft.Agents.AI.GitHub.Copilot/GitHubCopilotAgent.cs @@ -217,16 +217,15 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable } }); - List tempFiles = []; + string? tempDir = null; try { // Build prompt from text content string prompt = string.Join("\n", messages.Select(m => m.Text)); // Handle DataContent as attachments - List? attachments = await ProcessDataContentAttachmentsAsync( + (List? attachments, tempDir) = await ProcessDataContentAttachmentsAsync( messages, - tempFiles, cancellationToken).ConfigureAwait(false); // Send the message with attachments @@ -245,7 +244,7 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable } finally { - CleanupTempFiles(tempFiles); + CleanupTempDir(tempDir); } } finally @@ -410,45 +409,23 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable return new SessionConfig { Tools = mappedTools, SystemMessage = systemMessage }; } - private static readonly Dictionary s_mediaTypeExtensions = new(StringComparer.OrdinalIgnoreCase) - { - ["image/png"] = ".png", - ["image/jpeg"] = ".jpg", - ["image/jpg"] = ".jpg", - ["image/gif"] = ".gif", - ["image/webp"] = ".webp", - ["image/svg+xml"] = ".svg", - ["text/plain"] = ".txt", - ["text/html"] = ".html", - ["text/markdown"] = ".md", - ["application/json"] = ".json", - ["application/xml"] = ".xml", - ["application/pdf"] = ".pdf" - }; - - private static string GetExtensionForMediaType(string? mediaType) - { - return mediaType is not null && s_mediaTypeExtensions.TryGetValue(mediaType, out string? extension) ? extension : ".dat"; - } - - private static async Task?> ProcessDataContentAttachmentsAsync( + private static async Task<(List? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync( IEnumerable messages, - List tempFiles, CancellationToken cancellationToken) { List? attachments = null; + string? tempDir = null; foreach (ChatMessage message in messages) { foreach (AIContent content in message.Contents) { if (content is DataContent dataContent) { - // Write DataContent to a temp file - string tempFilePath = Path.Combine(Path.GetTempPath(), $"agentframework_copilot_data_{Guid.NewGuid()}{GetExtensionForMediaType(dataContent.MediaType)}"); - await File.WriteAllBytesAsync(tempFilePath, dataContent.Data.ToArray(), cancellationToken).ConfigureAwait(false); - tempFiles.Add(tempFilePath); + tempDir ??= Directory.CreateDirectory( + Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName; + + string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false); - // Create attachment attachments ??= []; attachments.Add(new UserMessageDataAttachmentsItem { @@ -460,19 +437,16 @@ public sealed class GitHubCopilotAgent : AIAgent, IAsyncDisposable } } - return attachments; + return (attachments, tempDir); } - private static void CleanupTempFiles(List tempFiles) + private static void CleanupTempDir(string? tempDir) { - foreach (string tempFile in tempFiles) + if (tempDir is not null) { try { - if (File.Exists(tempFile)) - { - File.Delete(tempFile); - } + Directory.Delete(tempDir, recursive: true); } catch { diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs index 6a51ce5805..bf5be5310f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/CopyConversationMessagesExecutor.cs @@ -42,14 +42,11 @@ internal sealed class CopyConversationMessagesExecutor(CopyConversationMessages private IEnumerable? GetInputMessages() { - DataValue? messages = null; + Throw.IfNull(this.Model.Messages, $"{nameof(this.Model)}.{nameof(this.Model.Messages)}"); - if (this.Model.Messages is not null) - { - EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Messages); - messages = expressionResult.Value; - } + EvaluationResult expressionResult = this.Evaluator.GetValue(this.Model.Messages); + DataValue messages = expressionResult.Value; - return messages?.ToChatMessages(); + return messages.ToChatMessages(); } } diff --git a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs index 380ea5eaeb..7649635aa7 100644 --- a/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs +++ b/dotnet/src/Shared/Workflows/Execution/WorkflowRunner.cs @@ -304,7 +304,7 @@ internal sealed class WorkflowRunner ChatMessage? responseMessage = requestItem switch { - FunctionCallContent functionCall => await InvokeFunctionAsync(functionCall).ConfigureAwait(false), + FunctionCallContent functionCall when !functionCall.InformationalOnly => await InvokeFunctionAsync(functionCall).ConfigureAwait(false), FunctionApprovalRequestContent functionApprovalRequest => ApproveFunction(functionApprovalRequest), McpServerToolApprovalRequestContent mcpApprovalRequest => ApproveMCP(mcpApprovalRequest), _ => HandleUnknown(requestItem), diff --git a/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs index 695f8a4825..d54a0644a4 100644 --- a/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.UnitTests/FunctionInvocationDelegatingAgentTests.cs @@ -524,8 +524,15 @@ public sealed class FunctionInvocationDelegatingAgentTests { // Arrange var testFunction = AIFunctionFactory.Create(() => "Function result", "TestFunction", "A test function"); - var functionCall = new FunctionCallContent("call_123", "TestFunction", new Dictionary()); - var mockChatClient = CreateMockChatClientWithFunctionCalls(functionCall); + var mockChatClient = new Mock(); + + mockChatClient.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => new ChatResponse([ + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call_123", "TestFunction", new Dictionary())]) + ])); var innerAgent = new ChatClientAgent(mockChatClient.Object); var messages = new List { new(ChatRole.User, "Test message") }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs new file mode 100644 index 0000000000..cb818fec15 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/CopyConversationMessagesExecutorTest.cs @@ -0,0 +1,202 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.Extensions; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; +using Microsoft.Agents.ObjectModel; +using Microsoft.Extensions.AI; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class CopyConversationMessagesExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task CopyMessagesWithSingleStringMessageAsync() + { + // Arrange, Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(CopyMessagesWithSingleStringMessageAsync), + conversationId: "TestConversationId", + messages: ValueExpression.Literal(StringDataValue.Create("Hello, how can I help you?")), + expectedMessageCount: 1); + } + + [Fact] + public async Task CopyMessagesWithSingleRecordMessageAsync() + { + // Arrange + ChatMessage testMessage = new(ChatRole.User, "Test message content"); + DataValue messageDataValue = testMessage.ToRecord().ToDataValue(); + Assert.IsType(messageDataValue); + RecordDataValue messageRecord = (RecordDataValue)messageDataValue; + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(CopyMessagesWithSingleRecordMessageAsync), + conversationId: "TestConversationId", + messages: ValueExpression.Literal(messageRecord), + expectedMessageCount: 1); + } + + [Fact] + public async Task CopyMessagesWithMultipleMessagesAsync() + { + // Arrange + List testMessages = + [ + new ChatMessage(ChatRole.User, "First message"), + new ChatMessage(ChatRole.Assistant, "Second message"), + new ChatMessage(ChatRole.User, "Third message") + ]; + DataValue messagesDataValue = testMessages.ToTable().ToDataValue(); + Assert.IsType(messagesDataValue); + TableDataValue messagesTable = (TableDataValue)messagesDataValue; + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(CopyMessagesWithMultipleMessagesAsync), + conversationId: "TestConversationId", + messages: ValueExpression.Literal(messagesTable), + expectedMessageCount: 3); + } + + [Fact] + public async Task CopyMessagesWithVariableExpressionAsync() + { + // Arrange + List testMessages = + [ + new ChatMessage(ChatRole.User, "Message from variable") + ]; + TableValue messagesTable = testMessages.ToTable(); + this.State.Set("SourceMessages", messagesTable); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(CopyMessagesWithVariableExpressionAsync), + conversationId: "TestConversationId", + messages: ValueExpression.Variable(PropertyPath.TopicVariable("SourceMessages")), + expectedMessageCount: 1); + } + + [Fact] + public async Task CopyMessagesToWorkflowConversationAsync() + { + // Arrange + this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System); + + List testMessages = + [ + new ChatMessage(ChatRole.User, "Message to workflow conversation") + ]; + DataValue messagesDataValue = testMessages.ToTable().ToDataValue(); + Assert.IsType(messagesDataValue); + TableDataValue messagesTable = (TableDataValue)messagesDataValue; + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(CopyMessagesToWorkflowConversationAsync), + conversationId: "WorkflowConversationId", + messages: ValueExpression.Literal(messagesTable), + expectedMessageCount: 1, + expectWorkflowEvent: true); + } + + [Fact] + public async Task CopyMessagesToNonWorkflowConversationAsync() + { + // Arrange + this.State.Set(SystemScope.Names.ConversationId, FormulaValue.New("WorkflowConversationId"), VariableScopeNames.System); + + List testMessages = + [ + new ChatMessage(ChatRole.User, "Message to non-workflow conversation") + ]; + DataValue messagesDataValue = testMessages.ToTable().ToDataValue(); + Assert.IsType(messagesDataValue); + TableDataValue messagesTable = (TableDataValue)messagesDataValue; + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(CopyMessagesToNonWorkflowConversationAsync), + conversationId: "DifferentConversationId", + messages: ValueExpression.Literal(messagesTable), + expectedMessageCount: 1, + expectWorkflowEvent: false); + } + + [Fact] + public async Task CopyMessagesWithBlankDataValueAsync() + { + // Arrange, Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(CopyMessagesWithBlankDataValueAsync), + conversationId: "TestConversationId", + messages: ValueExpression.Literal(DataValue.Blank()), + expectedMessageCount: 0); + } + + private async Task ExecuteTestAsync( + string displayName, + string conversationId, + ValueExpression messages, + int expectedMessageCount, + bool expectWorkflowEvent = false) + { + // Arrange + MockAgentProvider mockAgentProvider = new(); + mockAgentProvider.TestMessages.Clear(); + + CopyConversationMessages model = this.CreateModel( + this.FormatDisplayName(displayName), + conversationId, + messages); + + CopyConversationMessagesExecutor action = new(model, mockAgentProvider.Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action); + + // Assert + Assert.Equal(expectedMessageCount, mockAgentProvider.TestMessages.Count); + VerifyModel(model, action); + + AgentResponseEvent[] responseEvents = events.OfType().ToArray(); + if (expectWorkflowEvent && expectedMessageCount > 0) + { + Assert.NotEmpty(responseEvents); + AgentResponseEvent responseEvent = responseEvents.First(); + Assert.Equal(action.Id, responseEvent.ExecutorId); + Assert.NotNull(responseEvent.Response); + Assert.Equal(expectedMessageCount, responseEvent.Response.Messages.Count); + } + else + { + Assert.Empty(responseEvents); + } + } + + private CopyConversationMessages CreateModel( + string displayName, + string conversationId, + ValueExpression messages) + { + CopyConversationMessages.Builder actionBuilder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + ConversationId = StringExpression.Literal(conversationId), + Messages = messages + }; + + return AssignParent(actionBuilder); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/DefaultActionExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/DefaultActionExecutorTest.cs new file mode 100644 index 0000000000..0e7f0a4558 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/DefaultActionExecutorTest.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.ObjectModel; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class DefaultActionExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public async Task ExecuteDefaultActionAsync() + { + // Arrange, Act & Assert + await this.ExecuteTestAsync( + this.FormatDisplayName(nameof(ExecuteDefaultActionAsync))); + } + + private async Task ExecuteTestAsync(string displayName) + { + // Arrange + ResetVariable model = this.CreateModel(displayName); + + // Act + DefaultActionExecutor action = new(model, this.State); + WorkflowEvent[] events = await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + Assert.NotEmpty(events); + } + + private ResetVariable CreateModel(string displayName) + { + // Use a simple concrete action type since DialogAction.Builder is abstract + ResetVariable.Builder actionBuilder = + new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + Variable = PropertyPath.Create(FormatVariablePath("TestVariable")), + }; + + return AssignParent(actionBuilder); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs new file mode 100644 index 0000000000..6c422247f1 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/EditTableExecutorTest.cs @@ -0,0 +1,442 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Workflows.Declarative.ObjectModel; +using Microsoft.Agents.ObjectModel; +using Microsoft.PowerFx.Types; +using Xunit.Abstractions; + +namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests.ObjectModel; + +/// +/// Tests for . +/// +public sealed class EditTableExecutorTest(ITestOutputHelper output) : WorkflowActionExecutorTest(output) +{ + [Fact] + public void InvalidModelNullItemsVariable() => + // Arrange, Act, Assert + Assert.Throws(() => new EditTableExecutor(new EditTable(), this.State)); + + [Fact] + public async Task AddItemToTableAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 3}]"); + this.State.Set("MyTable", tableValue); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(AddItemToTableAsync), + variableName: "MyTable", + changeType: TableChangeType.Add, + value: new RecordDataValue([new("id", new NumberDataValue(7))])); + + // Verify the variable now contains the added record + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); + Assert.Equal(7, idValue.Value); + } + + [Fact] + public async Task AddItemWithMultipleFieldsAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 1, name: \"First\"}]"); + this.State.Set("MyTable", tableValue); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(AddItemWithMultipleFieldsAsync), + variableName: "MyTable", + changeType: TableChangeType.Add, + value: new RecordDataValue([ + new("id", new NumberDataValue(2)), + new("name", new StringDataValue("Second")) + ])); + + // Verify the variable now contains the added record + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); + Assert.Equal(2, idValue.Value); + StringValue nameValue = Assert.IsType(resultRecord.GetField("name")); + Assert.Equal("Second", nameValue.Value); + } + + [Fact] + public async Task AddItemToEmptyTableAsync() + { + // Arrange - Initialize empty table using Power FX expression with schema + FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})"); + TableValue table = Assert.IsAssignableFrom(tableValue); + // Clear the table to make it empty but preserve schema + await table.ClearAsync(CancellationToken.None); + this.State.Set("MyTable", table); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(AddItemToEmptyTableAsync), + variableName: "MyTable", + changeType: TableChangeType.Add, + value: new RecordDataValue([new("id", new NumberDataValue(1))])); + + // Verify the variable now contains the added record + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); + Assert.Equal(1, idValue.Value); + } + + [Fact] + public async Task RemoveItemFromTableAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 3}, {id: 7}]"); + this.State.Set("MyTable", tableValue); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(RemoveItemFromTableAsync), + variableName: "MyTable", + changeType: TableChangeType.Remove, + value: new TableDataValue([new RecordDataValue([new("id", new NumberDataValue(3))])])); + + // Verify the variable now contains an empty record + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + // Empty record should have no fields + Assert.Empty(resultRecord.Fields); + } + + [Fact] + public async Task RemoveMultipleItemsFromTableAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}, {id: 3}]"); + this.State.Set("MyTable", tableValue); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(RemoveMultipleItemsFromTableAsync), + variableName: "MyTable", + changeType: TableChangeType.Remove, + value: new TableDataValue([ + new RecordDataValue([new("id", new NumberDataValue(1))]), + new RecordDataValue([new("id", new NumberDataValue(3))]) + ])); + + // Verify the variable now contains an empty record + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + // Empty record should have no fields + Assert.Empty(resultRecord.Fields); + } + + [Fact] + public async Task ClearTableAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}]"); + this.State.Set("MyTable", tableValue); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(ClearTableAsync), + variableName: "MyTable", + changeType: TableChangeType.Clear, + value: null); + + // Verify table is cleared + FormulaValue resultValue = this.State.Get("MyTable"); + Assert.IsType(resultValue); + } + + [Fact] + public async Task ClearEmptyTableAsync() + { + // Arrange - Initialize empty table using Power FX expression with schema + FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})"); + TableValue table = Assert.IsAssignableFrom(tableValue); + // Clear the table to make it empty but preserve schema + await table.ClearAsync(CancellationToken.None); + this.State.Set("MyTable", table); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(ClearEmptyTableAsync), + variableName: "MyTable", + changeType: TableChangeType.Clear, + value: null); + + // Verify table is blank + FormulaValue resultValue = this.State.Get("MyTable"); + Assert.IsType(resultValue); + } + + [Fact] + public async Task TakeFirstItemAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]"); + this.State.Set("MyTable", tableValue); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeFirstItemAsync), + variableName: "MyTable", + changeType: TableChangeType.TakeFirst, + value: null); + + // Verify the variable now contains the first record that was taken + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); + Assert.Equal(10, idValue.Value); + } + + [Fact] + public async Task TakeFirstFromEmptyTableAsync() + { + // Arrange - Initialize empty table using Power FX expression with schema + FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})"); + TableValue table = Assert.IsAssignableFrom(tableValue); + // Clear the table to make it empty but preserve schema + await table.ClearAsync(CancellationToken.None); + this.State.Set("MyTable", table); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeFirstFromEmptyTableAsync), + variableName: "MyTable", + changeType: TableChangeType.TakeFirst, + value: null); + + // Verify table is still empty (nothing was taken, variable remains unchanged) + FormulaValue resultValue = this.State.Get("MyTable"); + TableValue resultTable = Assert.IsAssignableFrom(resultValue); + Assert.Empty(resultTable.Rows); + } + + [Fact] + public async Task TakeLastItemAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 10}, {id: 20}, {id: 30}]"); + this.State.Set("MyTable", tableValue); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeLastItemAsync), + variableName: "MyTable", + changeType: TableChangeType.TakeLast, + value: null); + + // Verify the variable now contains the last record that was taken + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); + Assert.Equal(30, idValue.Value); + } + + [Fact] + public async Task TakeLastFromEmptyTableAsync() + { + // Arrange - Initialize empty table using Power FX expression with schema + FormulaValue tableValue = this.State.Engine.Eval("Table({id: 1})"); + TableValue table = Assert.IsAssignableFrom(tableValue); + // Clear the table to make it empty but preserve schema + await table.ClearAsync(CancellationToken.None); + this.State.Set("MyTable", table); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeLastFromEmptyTableAsync), + variableName: "MyTable", + changeType: TableChangeType.TakeLast, + value: null); + + // Verify table is still empty (nothing was taken, variable remains unchanged) + FormulaValue resultValue = this.State.Get("MyTable"); + TableValue resultTable = Assert.IsAssignableFrom(resultValue); + Assert.Empty(resultTable.Rows); + } + + [Fact] + public async Task TakeFirstFromSingleItemTableAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 100}]"); + this.State.Set("MyTable", tableValue); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeFirstFromSingleItemTableAsync), + variableName: "MyTable", + changeType: TableChangeType.TakeFirst, + value: null); + + // Verify variable contains the record that was taken + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); + Assert.Equal(100, idValue.Value); + } + + [Fact] + public async Task TakeLastFromSingleItemTableAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 100}]"); + this.State.Set("MyTable", tableValue); + + // Act, Assert + await this.ExecuteTestAsync( + displayName: nameof(TakeLastFromSingleItemTableAsync), + variableName: "MyTable", + changeType: TableChangeType.TakeLast, + value: null); + + // Verify variable contains the record that was taken + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); + Assert.Equal(100, idValue.Value); + } + + [Fact] + public async Task ErrorWhenVariableIsNotTableAsync() + { + // Arrange + this.State.Set("NotATable", FormulaValue.New("This is a string, not a table")); + + EditTable model = this.CreateModel( + nameof(ErrorWhenVariableIsNotTableAsync), + "NotATable", + TableChangeType.Add, + new RecordDataValue([new("id", new NumberDataValue(1))])); + + // Act + EditTableExecutor action = new(model, this.State); + + // Assert - Should throw an exception for non-table variable + DeclarativeActionException exception = await Assert.ThrowsAsync( + async () => await this.ExecuteAsync(action)); + Assert.NotNull(exception); + } + + [Fact] + public async Task AddWithExpressionAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 5}]"); + this.State.Set("MyTable", tableValue); + this.State.Set("NewId", FormulaValue.New(10)); + + EditTable model = this.CreateModel( + nameof(AddWithExpressionAsync), + "MyTable", + TableChangeType.Add, + ValueExpression.Expression("{id: Local.NewId}")); + + // Act + EditTableExecutor action = new(model, this.State); + await this.ExecuteAsync(action); + + // Assert - Variable should contain the newly added record + VerifyModel(model, action); + FormulaValue resultValue = this.State.Get("MyTable"); + RecordValue resultRecord = Assert.IsAssignableFrom(resultValue); + DecimalValue idValue = Assert.IsType(resultRecord.GetField("id")); + Assert.Equal(10, idValue.Value); + } + + [Fact] + public async Task RemoveWithNonTableValueAsync() + { + // Arrange - Initialize table using Power FX expression + FormulaValue tableValue = this.State.Engine.Eval("[{id: 1}, {id: 2}]"); + this.State.Set("MyTable", tableValue); + + // Try to remove using a non-table value (should not throw, just not remove anything) + EditTable model = this.CreateModel( + nameof(RemoveWithNonTableValueAsync), + "MyTable", + TableChangeType.Remove, + new RecordDataValue([new("id", new NumberDataValue(1))])); + + // Act + EditTableExecutor action = new(model, this.State); + await this.ExecuteAsync(action); + + // Assert - table should remain unchanged since value is not a TableDataValue + VerifyModel(model, action); + FormulaValue resultValue = this.State.Get("MyTable"); + TableValue resultTable = Assert.IsAssignableFrom(resultValue); + Assert.Equal(2, resultTable.Rows.Count()); + } + + private async Task ExecuteTestAsync( + string displayName, + string variableName, + TableChangeType changeType, + DataValue? value) + { + // Arrange + EditTable model = this.CreateModel(displayName, variableName, changeType, value); + + // Act + EditTableExecutor action = new(model, this.State); + await this.ExecuteAsync(action); + + // Assert + VerifyModel(model, action); + } + + private EditTable CreateModel( + string displayName, + string variableName, + TableChangeType changeType, + DataValue? value) + { + ValueExpression.Builder? valueExpressionBuilder = value switch + { + null => null, + _ => new ValueExpression.Builder(ValueExpression.Literal(value)) + }; + + return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder); + } + + private EditTable CreateModel( + string displayName, + string variableName, + TableChangeType changeType, + ValueExpression valueExpression) + { + ValueExpression.Builder valueExpressionBuilder = new(valueExpression); + return this.CreateModel(displayName, variableName, changeType, valueExpressionBuilder); + } + + private EditTable CreateModel( + string displayName, + string variableName, + TableChangeType changeType, + ValueExpression.Builder? valueExpression) + { + EditTable.Builder actionBuilder = new() + { + Id = this.CreateActionId(), + DisplayName = this.FormatDisplayName(displayName), + ItemsVariable = PropertyPath.Create(FormatVariablePath(variableName)), + ChangeType = TableChangeTypeWrapper.Get(changeType), + Value = valueExpression, + }; + + return AssignParent(actionBuilder); + } +} diff --git a/python/.cspell.json b/python/.cspell.json index db575845e8..a26cc7fed7 100644 --- a/python/.cspell.json +++ b/python/.cspell.json @@ -24,8 +24,8 @@ ], "words": [ "aeiou", - "aiplatform", "agui", + "aiplatform", "azuredocindex", "azuredocs", "azurefunctions", @@ -57,20 +57,22 @@ "nopep", "NOSQL", "ollama", - "otlp", "Onnx", "onyourdatatest", "OPENAI", "opentelemetry", "OTEL", + "otlp", "powerfx", "protos", "pydantic", "pytestmark", "qdrant", "retrywrites", - "streamable", "serde", + "streamable", + "superstep", + "supersteps", "templating", "uninstrument", "vectordb", diff --git a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py index 91bba87f12..bf9992d7ff 100644 --- a/python/packages/anthropic/agent_framework_anthropic/_chat_client.py +++ b/python/packages/anthropic/agent_framework_anthropic/_chat_client.py @@ -27,7 +27,7 @@ from agent_framework import ( get_logger, prepare_function_call_results, ) -from agent_framework._pydantic import AFBaseSettings +from agent_framework._settings import SecretString, load_settings from agent_framework._types import _get_data_bytes_as_str # type: ignore from agent_framework.exceptions import ServiceInitializationError from agent_framework.observability import ChatTelemetryLayer @@ -47,7 +47,7 @@ from anthropic.types.beta.beta_bash_code_execution_tool_result_error import ( from anthropic.types.beta.beta_code_execution_tool_result_error import ( BetaCodeExecutionToolResultError, ) -from pydantic import BaseModel, SecretStr, ValidationError +from pydantic import BaseModel if sys.version_info >= (3, 11): from typing import TypedDict # type: ignore # pragma: no cover @@ -192,40 +192,20 @@ FINISH_REASON_MAP: dict[str, FinishReasonLiteral] = { } -class AnthropicSettings(AFBaseSettings): +class AnthropicSettings(TypedDict, total=False): """Anthropic Project settings. The settings are first loaded from environment variables with the prefix 'ANTHROPIC_'. If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. If the settings are not found in the .env file, the settings - are ignored; however, validation will fail alerting that the settings are missing. + with the encoding 'utf-8'. - Keyword Args: + Keys: api_key: The Anthropic API key. chat_model_id: The Anthropic chat model ID. - env_file_path: If provided, the .env settings are read from this file path location. - env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. - - Examples: - .. code-block:: python - - from agent_framework.anthropic import AnthropicSettings - - # Using environment variables - # Set ANTHROPIC_API_KEY=your_anthropic_api_key - # ANTHROPIC_CHAT_MODEL_ID=claude-sonnet-4-5-20250929 - - # Or passing parameters directly - settings = AnthropicSettings(chat_model_id="claude-sonnet-4-5-20250929") - - # Or loading from a .env file - settings = AnthropicSettings(env_file_path="path/to/.env") """ - env_prefix: ClassVar[str] = "ANTHROPIC_" - - api_key: SecretStr | None = None - chat_model_id: str | None = None + api_key: SecretString | None + chat_model_id: str | None class AnthropicClient( @@ -311,25 +291,24 @@ class AnthropicClient( response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - try: - anthropic_settings = AnthropicSettings( - api_key=api_key, # type: ignore[arg-type] - chat_model_id=model_id, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Anthropic settings.", ex) from ex + anthropic_settings = load_settings( + AnthropicSettings, + env_prefix="ANTHROPIC_", + api_key=api_key, + chat_model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) if anthropic_client is None: - if not anthropic_settings.api_key: + if not anthropic_settings["api_key"]: raise ServiceInitializationError( "Anthropic API key is required. Set via 'api_key' parameter " "or 'ANTHROPIC_API_KEY' environment variable." ) anthropic_client = AsyncAnthropic( - api_key=anthropic_settings.api_key.get_secret_value(), + api_key=anthropic_settings["api_key"].get_secret_value(), default_headers={"User-Agent": AGENT_FRAMEWORK_USER_AGENT}, ) @@ -343,7 +322,7 @@ class AnthropicClient( # Initialize instance variables self.anthropic_client = anthropic_client self.additional_beta_flags = additional_beta_flags or [] - self.model_id = anthropic_settings.chat_model_id + self.model_id = anthropic_settings["chat_model_id"] # streaming requires tracking the last function call ID and name self._last_call_id_name: tuple[str, str] | None = None diff --git a/python/packages/anthropic/tests/test_anthropic_client.py b/python/packages/anthropic/tests/test_anthropic_client.py index 80d57de07f..ff9234f60b 100644 --- a/python/packages/anthropic/tests/test_anthropic_client.py +++ b/python/packages/anthropic/tests/test_anthropic_client.py @@ -13,6 +13,7 @@ from agent_framework import ( SupportsChatGetResponse, tool, ) +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError from anthropic.types.beta import ( BetaMessage, @@ -20,7 +21,7 @@ from anthropic.types.beta import ( BetaToolUseBlock, BetaUsage, ) -from pydantic import Field, ValidationError +from pydantic import Field from agent_framework_anthropic import AnthropicClient from agent_framework_anthropic._chat_client import AnthropicSettings @@ -41,8 +42,12 @@ def create_test_anthropic_client( ) -> AnthropicClient: """Helper function to create AnthropicClient instances for testing, bypassing normal validation.""" if anthropic_settings is None: - anthropic_settings = AnthropicSettings( - api_key="test-api-key-12345", chat_model_id="claude-3-5-sonnet-20241022", env_file_path="test.env" + anthropic_settings = load_settings( + AnthropicSettings, + env_prefix="ANTHROPIC_", + api_key="test-api-key-12345", + chat_model_id="claude-3-5-sonnet-20241022", + env_file_path="test.env", ) # Create client instance directly @@ -50,7 +55,7 @@ def create_test_anthropic_client( # Set attributes directly client.anthropic_client = mock_anthropic_client - client.model_id = model_id or anthropic_settings.chat_model_id + client.model_id = model_id or anthropic_settings["chat_model_id"] client._last_call_id_name = None client.additional_properties = {} client.middleware = None @@ -64,30 +69,34 @@ def create_test_anthropic_client( def test_anthropic_settings_init(anthropic_unit_test_env: dict[str, str]) -> None: """Test AnthropicSettings initialization.""" - settings = AnthropicSettings(env_file_path="test.env") + settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_", env_file_path="test.env") - assert settings.api_key is not None - assert settings.api_key.get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"] - assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + assert settings["api_key"] is not None + assert settings["api_key"].get_secret_value() == anthropic_unit_test_env["ANTHROPIC_API_KEY"] + assert settings["chat_model_id"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] def test_anthropic_settings_init_with_explicit_values() -> None: """Test AnthropicSettings initialization with explicit values.""" - settings = AnthropicSettings( - api_key="custom-api-key", chat_model_id="claude-3-opus-20240229", env_file_path="test.env" + settings = load_settings( + AnthropicSettings, + env_prefix="ANTHROPIC_", + api_key="custom-api-key", + chat_model_id="claude-3-opus-20240229", + env_file_path="test.env", ) - assert settings.api_key is not None - assert settings.api_key.get_secret_value() == "custom-api-key" - assert settings.chat_model_id == "claude-3-opus-20240229" + assert settings["api_key"] is not None + assert settings["api_key"].get_secret_value() == "custom-api-key" + assert settings["chat_model_id"] == "claude-3-opus-20240229" @pytest.mark.parametrize("exclude_list", [["ANTHROPIC_API_KEY"]], indirect=True) def test_anthropic_settings_missing_api_key(anthropic_unit_test_env: dict[str, str]) -> None: """Test AnthropicSettings when API key is missing.""" - settings = AnthropicSettings(env_file_path="test.env") - assert settings.api_key is None - assert settings.chat_model_id == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] + settings = load_settings(AnthropicSettings, env_prefix="ANTHROPIC_", env_file_path="test.env") + assert settings["api_key"] is None + assert settings["chat_model_id"] == anthropic_unit_test_env["ANTHROPIC_CHAT_MODEL_ID"] # Client Initialization Tests @@ -116,23 +125,13 @@ def test_anthropic_client_init_auto_create_client(anthropic_unit_test_env: dict[ def test_anthropic_client_init_missing_api_key() -> None: """Test AnthropicClient initialization when API key is missing.""" - with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings: - mock_settings.return_value.api_key = None - mock_settings.return_value.chat_model_id = "claude-3-5-sonnet-20241022" + with patch("agent_framework_anthropic._chat_client.load_settings") as mock_load: + mock_load.return_value = {"api_key": None, "chat_model_id": "claude-3-5-sonnet-20241022"} with pytest.raises(ServiceInitializationError, match="Anthropic API key is required"): AnthropicClient() -def test_anthropic_client_init_validation_error() -> None: - """Test that ValidationError in AnthropicSettings is properly handled.""" - with patch("agent_framework_anthropic._chat_client.AnthropicSettings") as mock_settings: - mock_settings.side_effect = ValidationError.from_exception_data("test", []) - - with pytest.raises(ServiceInitializationError, match="Failed to create Anthropic settings"): - AnthropicClient() - - def test_anthropic_client_service_url(mock_anthropic_client: MagicMock) -> None: """Test service_url method.""" client = create_test_anthropic_client(mock_anthropic_client) diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py index bad955d57e..091695165d 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_context_provider.py @@ -16,6 +16,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Literal from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Message from agent_framework._logging import get_logger from agent_framework._sessions import AgentSession, BaseContextProvider, SessionContext +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError from azure.core.credentials import AzureKeyCredential from azure.core.credentials_async import AsyncTokenCredential @@ -41,7 +42,6 @@ from azure.search.documents.models import ( VectorizableTextQuery, VectorizedQuery, ) -from pydantic import ValidationError from ._search_provider import AzureAISearchSettings @@ -180,40 +180,39 @@ class _AzureAISearchContextProvider(BaseContextProvider): super().__init__(source_id) # Load settings from environment/file - try: - settings = AzureAISearchSettings( - endpoint=endpoint, - index_name=index_name, - knowledge_base_name=knowledge_base_name, - api_key=api_key if isinstance(api_key, str) else None, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Azure AI Search settings.", ex) from ex + settings = load_settings( + AzureAISearchSettings, + env_prefix="AZURE_SEARCH_", + endpoint=endpoint, + index_name=index_name, + knowledge_base_name=knowledge_base_name, + api_key=api_key if isinstance(api_key, str) else None, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) - if not settings.endpoint: + if not settings.get("endpoint"): raise ServiceInitializationError( "Azure AI Search endpoint is required. Set via 'endpoint' parameter " "or 'AZURE_SEARCH_ENDPOINT' environment variable." ) if mode == "semantic": - if not settings.index_name: + if not settings.get("index_name"): raise ServiceInitializationError( "Azure AI Search index name is required for semantic mode. " "Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable." ) elif mode == "agentic": - if settings.index_name and settings.knowledge_base_name: + if settings.get("index_name") and settings.get("knowledge_base_name"): raise ServiceInitializationError( "For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both." ) - if not settings.index_name and not settings.knowledge_base_name: + if not settings.get("index_name") and not settings.get("knowledge_base_name"): raise ServiceInitializationError( "For agentic mode, provide either 'index_name' or 'knowledge_base_name'." ) - if settings.index_name and not model_deployment_name: + if settings.get("index_name") and not model_deployment_name: raise ServiceInitializationError( "model_deployment_name is required for agentic mode when creating Knowledge Base from index." ) @@ -223,16 +222,16 @@ class _AzureAISearchContextProvider(BaseContextProvider): resolved_credential = credential elif isinstance(api_key, AzureKeyCredential): resolved_credential = api_key - elif settings.api_key: - resolved_credential = AzureKeyCredential(settings.api_key.get_secret_value()) + elif settings.get("api_key"): + resolved_credential = AzureKeyCredential(settings["api_key"].get_secret_value()) # type: ignore[union-attr] else: raise ServiceInitializationError( "Azure credential is required. Provide 'api_key' or 'credential' parameter " "or set 'AZURE_SEARCH_API_KEY' environment variable." ) - self.endpoint = settings.endpoint - self.index_name = settings.index_name + self.endpoint: str = settings["endpoint"] # type: ignore[assignment] # validated above + self.index_name = settings.get("index_name") self.credential = resolved_credential self.mode = mode self.top_k = top_k @@ -244,7 +243,7 @@ class _AzureAISearchContextProvider(BaseContextProvider): self.azure_openai_resource_url = azure_openai_resource_url self.azure_openai_deployment_name = model_deployment_name self.model_name = model_name or model_deployment_name - self.knowledge_base_name = settings.knowledge_base_name + self.knowledge_base_name = settings.get("knowledge_base_name") self.retrieval_instructions = retrieval_instructions self.azure_openai_api_key = azure_openai_api_key self.knowledge_base_output_mode = knowledge_base_output_mode @@ -253,10 +252,10 @@ class _AzureAISearchContextProvider(BaseContextProvider): self._use_existing_knowledge_base = False if mode == "agentic": - if settings.knowledge_base_name: + if settings.get("knowledge_base_name"): self._use_existing_knowledge_base = True else: - self.knowledge_base_name = f"{settings.index_name}-kb" + self.knowledge_base_name = f"{settings.get('index_name', '')}-kb" self._auto_discovered_vector_field = False self._use_vectorizable_query = False diff --git a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py index 332c477d85..5e47b37b00 100644 --- a/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py +++ b/python/packages/azure-ai-search/agent_framework_azure_ai_search/_search_provider.py @@ -5,11 +5,11 @@ from __future__ import annotations import sys from collections.abc import Awaitable, Callable, MutableSequence -from typing import TYPE_CHECKING, Any, ClassVar, Literal +from typing import TYPE_CHECKING, Any, Literal from agent_framework import AGENT_FRAMEWORK_USER_AGENT, Context, ContextProvider, Message from agent_framework._logging import get_logger -from agent_framework._pydantic import AFBaseSettings +from agent_framework._settings import SecretString, load_settings from agent_framework.exceptions import ServiceInitializationError from azure.core.credentials import AzureKeyCredential from azure.core.credentials_async import AsyncTokenCredential @@ -35,7 +35,6 @@ from azure.search.documents.models import ( VectorizableTextQuery, VectorizedQuery, ) -from pydantic import SecretStr, ValidationError # Type checking imports for optional agentic mode dependencies if TYPE_CHECKING: @@ -99,9 +98,9 @@ else: from typing_extensions import override # type: ignore[import] # pragma: no cover if sys.version_info >= (3, 11): - from typing import Self # pragma: no cover + from typing import Self, TypedDict # pragma: no cover else: - from typing_extensions import Self # pragma: no cover + from typing_extensions import Self, TypedDict # pragma: no cover """Azure AI Search Context Provider for Agent Framework. @@ -120,7 +119,7 @@ logger = get_logger("agent_framework.azure") _DEFAULT_AGENTIC_MESSAGE_HISTORY_COUNT = 10 -class AzureAISearchSettings(AFBaseSettings): +class AzureAISearchSettings(TypedDict, total=False): """Settings for Azure AI Search Context Provider with auto-loading from environment. The settings are first loaded from environment variables with the prefix 'AZURE_SEARCH_'. @@ -158,12 +157,10 @@ class AzureAISearchSettings(AFBaseSettings): settings = AzureAISearchSettings(env_file_path="path/to/.env") """ - env_prefix: ClassVar[str] = "AZURE_SEARCH_" - - endpoint: str | None = None - index_name: str | None = None - knowledge_base_name: str | None = None - api_key: SecretStr | None = None + endpoint: str | None + index_name: str | None + knowledge_base_name: str | None + api_key: SecretString | None class AzureAISearchContextProvider(ContextProvider): @@ -336,42 +333,41 @@ class AzureAISearchContextProvider(ContextProvider): provider = AzureAISearchContextProvider(credential=credential, env_file_path="path/to/.env") """ # Load settings from environment/file - try: - settings = AzureAISearchSettings( - endpoint=endpoint, - index_name=index_name, - knowledge_base_name=knowledge_base_name, - api_key=api_key if isinstance(api_key, str) else None, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Azure AI Search settings.", ex) from ex + settings = load_settings( + AzureAISearchSettings, + env_prefix="AZURE_SEARCH_", + endpoint=endpoint, + index_name=index_name, + knowledge_base_name=knowledge_base_name, + api_key=api_key if isinstance(api_key, str) else None, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) # Validate required parameters - if not settings.endpoint: + if not settings.get("endpoint"): raise ServiceInitializationError( "Azure AI Search endpoint is required. Set via 'endpoint' parameter " "or 'AZURE_SEARCH_ENDPOINT' environment variable." ) # Validate index_name and knowledge_base_name based on mode - # Note: settings.* contains the resolved value (explicit param OR env var) + # Note: settings["field"] / settings.get("field") contains the resolved value (explicit param OR env var) if mode == "semantic": # Semantic mode: always requires index_name - if not settings.index_name: + if not settings.get("index_name"): raise ServiceInitializationError( "Azure AI Search index name is required for semantic mode. " "Set via 'index_name' parameter or 'AZURE_SEARCH_INDEX_NAME' environment variable." ) elif mode == "agentic": # Agentic mode: requires exactly ONE of index_name or knowledge_base_name - if settings.index_name and settings.knowledge_base_name: + if settings.get("index_name") and settings.get("knowledge_base_name"): raise ServiceInitializationError( "For agentic mode, provide either 'index_name' OR 'knowledge_base_name', not both. " "Use 'index_name' to auto-create a Knowledge Base, or 'knowledge_base_name' to use an existing one." ) - if not settings.index_name and not settings.knowledge_base_name: + if not settings.get("index_name") and not settings.get("knowledge_base_name"): raise ServiceInitializationError( "For agentic mode, provide either 'index_name' (to auto-create Knowledge Base) " "or 'knowledge_base_name' (to use existing Knowledge Base). " @@ -379,7 +375,7 @@ class AzureAISearchContextProvider(ContextProvider): "AZURE_SEARCH_INDEX_NAME / AZURE_SEARCH_KNOWLEDGE_BASE_NAME." ) # If using index_name to create KB, model config is required - if settings.index_name and not model_deployment_name: + if settings.get("index_name") and not model_deployment_name: raise ServiceInitializationError( "model_deployment_name is required for agentic mode when creating Knowledge Base from index. " "This is the Azure OpenAI deployment used by the Knowledge Base for query planning." @@ -392,16 +388,16 @@ class AzureAISearchContextProvider(ContextProvider): resolved_credential = credential elif isinstance(api_key, AzureKeyCredential): resolved_credential = api_key - elif settings.api_key: - resolved_credential = AzureKeyCredential(settings.api_key.get_secret_value()) + elif resolved_api_key := settings.get("api_key"): + resolved_credential = AzureKeyCredential(resolved_api_key.get_secret_value()) else: raise ServiceInitializationError( "Azure credential is required. Provide 'api_key' or 'credential' parameter " "or set 'AZURE_SEARCH_API_KEY' environment variable." ) - self.endpoint = settings.endpoint - self.index_name = settings.index_name + self.endpoint: str = settings["endpoint"] # type: ignore[assignment] # validated above + self.index_name = settings.get("index_name") self.credential = resolved_credential self.mode = mode self.top_k = top_k @@ -416,7 +412,7 @@ class AzureAISearchContextProvider(ContextProvider): # If model_name not provided, default to deployment name self.model_name = model_name or model_deployment_name # Use resolved KB name (from explicit param or env var) - self.knowledge_base_name = settings.knowledge_base_name + self.knowledge_base_name = settings.get("knowledge_base_name") self.retrieval_instructions = retrieval_instructions self.azure_openai_api_key = azure_openai_api_key self.knowledge_base_output_mode = knowledge_base_output_mode @@ -429,12 +425,12 @@ class AzureAISearchContextProvider(ContextProvider): # - index_name provided: auto-create KB from index self._use_existing_knowledge_base = False if mode == "agentic": - if settings.knowledge_base_name: + if settings.get("knowledge_base_name"): # Use existing KB directly (supports any source type: web, blob, index, etc.) self._use_existing_knowledge_base = True else: # Auto-generate KB name from index name - self.knowledge_base_name = f"{settings.index_name}-kb" + self.knowledge_base_name = f"{settings.get('index_name', '')}-kb" # Auto-discover vector field if not specified self._auto_discovered_vector_field = False diff --git a/python/packages/azure-ai-search/tests/test_search_provider.py b/python/packages/azure-ai-search/tests/test_search_provider.py index def95cd732..bcdbb9b5ef 100644 --- a/python/packages/azure-ai-search/tests/test_search_provider.py +++ b/python/packages/azure-ai-search/tests/test_search_provider.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import Context, Message +from agent_framework._settings import load_settings from agent_framework.azure import AzureAISearchContextProvider, AzureAISearchSettings from agent_framework.exceptions import ServiceInitializationError from azure.core.credentials import AzureKeyCredential @@ -48,25 +49,27 @@ class TestAzureAISearchSettings: def test_settings_with_direct_values(self) -> None: """Test settings with direct values.""" - settings = AzureAISearchSettings( + settings = load_settings( + AzureAISearchSettings, + env_prefix="AZURE_SEARCH_", endpoint="https://test.search.windows.net", index_name="test-index", api_key="test-key", ) - assert settings.endpoint == "https://test.search.windows.net" - assert settings.index_name == "test-index" - # api_key is now SecretStr - assert settings.api_key.get_secret_value() == "test-key" + assert settings["endpoint"] == "https://test.search.windows.net" + assert settings["index_name"] == "test-index" + assert settings["api_key"] == "test-key" def test_settings_with_env_file_path(self) -> None: """Test settings with env_file_path parameter.""" - settings = AzureAISearchSettings( + settings = load_settings( + AzureAISearchSettings, + env_prefix="AZURE_SEARCH_", endpoint="https://test.search.windows.net", index_name="test-index", - env_file_path="test.env", ) - assert settings.endpoint == "https://test.search.windows.net" - assert settings.index_name == "test-index" + assert settings["endpoint"] == "https://test.search.windows.net" + assert settings["index_name"] == "test-index" def test_provider_uses_settings_from_env(self) -> None: """Test that provider creates settings internally from env.""" diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py index c6d68daaa2..5ea9983c50 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_agent_provider.py @@ -15,12 +15,13 @@ from agent_framework import ( normalize_tools, ) from agent_framework._mcp import MCPTool +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError from azure.ai.agents.aio import AgentsClient from azure.ai.agents.models import Agent as AzureAgent from azure.ai.agents.models import ResponseFormatJsonSchema, ResponseFormatJsonSchemaType from azure.core.credentials_async import AsyncTokenCredential -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from ._chat_client import AzureAIAgentClient, AzureAIAgentOptions from ._shared import AzureAISettings, from_azure_ai_agent_tools, to_azure_ai_agent_tools @@ -112,21 +113,21 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): Raises: ServiceInitializationError: If required parameters are missing or invalid. """ - try: - self._settings = AzureAISettings( - project_endpoint=project_endpoint, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Azure AI settings.", ex) from ex + self._settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + project_endpoint=project_endpoint, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) self._should_close_client = False if agents_client is not None: self._agents_client = agents_client else: - if not self._settings.project_endpoint: + resolved_endpoint = self._settings.get("project_endpoint") + if not resolved_endpoint: raise ServiceInitializationError( "Azure AI project endpoint is required. Provide 'project_endpoint' parameter " "or set 'AZURE_AI_PROJECT_ENDPOINT' environment variable." @@ -134,7 +135,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): if not credential: raise ServiceInitializationError("Azure credential is required when agents_client is not provided.") self._agents_client = AgentsClient( - endpoint=self._settings.project_endpoint, + endpoint=resolved_endpoint, credential=credential, user_agent=AGENT_FRAMEWORK_USER_AGENT, ) @@ -211,7 +212,7 @@ class AzureAIAgentsProvider(Generic[OptionsCoT]): tools=get_weather, ) """ - resolved_model = model or self._settings.model_deployment_name + resolved_model = model or self._settings.get("model_deployment_name") if not resolved_model: raise ServiceInitializationError( "Model deployment name is required. Provide 'model' parameter " diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py index 7a11734908..ffb39e6c25 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_chat_client.py @@ -35,6 +35,7 @@ from agent_framework import ( get_logger, prepare_function_call_results, ) +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException from agent_framework.observability import ChatTelemetryLayer from azure.ai.agents.aio import AgentsClient @@ -85,7 +86,7 @@ from azure.ai.agents.models import ( ToolOutput, ) from azure.core.credentials_async import AsyncTokenCredential -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from ._shared import AzureAISettings, to_azure_ai_agent_tools @@ -482,26 +483,26 @@ class AzureAIAgentClient( client: AzureAIAgentClient[MyOptions] = AzureAIAgentClient(credential=credential) response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - try: - azure_ai_settings = AzureAISettings( - project_endpoint=project_endpoint, - model_deployment_name=model_deployment_name, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Azure AI settings.", ex) from ex + azure_ai_settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + project_endpoint=project_endpoint, + model_deployment_name=model_deployment_name, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) # If no agents_client is provided, create one should_close_client = False if agents_client is None: - if not azure_ai_settings.project_endpoint: + resolved_endpoint = azure_ai_settings.get("project_endpoint") + if not resolved_endpoint: raise ServiceInitializationError( "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." ) - if agent_id is None and not azure_ai_settings.model_deployment_name: + if agent_id is None and not azure_ai_settings.get("model_deployment_name"): raise ServiceInitializationError( "Azure AI model deployment name is required. Set via 'model_deployment_name' parameter " "or 'AZURE_AI_MODEL_DEPLOYMENT_NAME' environment variable." @@ -511,7 +512,7 @@ class AzureAIAgentClient( if not credential: raise ServiceInitializationError("Azure credential is required when agents_client is not provided.") agents_client = AgentsClient( - endpoint=azure_ai_settings.project_endpoint, + endpoint=resolved_endpoint, credential=credential, user_agent=AGENT_FRAMEWORK_USER_AGENT, ) @@ -530,7 +531,7 @@ class AzureAIAgentClient( self.agent_id = agent_id self.agent_name = agent_name self.agent_description = agent_description - self.model_id = azure_ai_settings.model_deployment_name + self.model_id = azure_ai_settings.get("model_deployment_name") self.thread_id = thread_id self.should_cleanup_agent = should_cleanup_agent # Track whether we should delete the agent self._agent_created = False # Track whether agent was created inside this class diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_client.py b/python/packages/azure-ai/agent_framework_azure_ai/_client.py index 03fb5e84a8..a5881c4c2a 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_client.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_client.py @@ -20,6 +20,7 @@ from agent_framework import ( MiddlewareTypes, get_logger, ) +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError from agent_framework.observability import ChatTelemetryLayer from agent_framework.openai import OpenAIResponsesOptions @@ -40,7 +41,6 @@ from azure.ai.projects.models import ( from azure.ai.projects.models import FileSearchTool as ProjectsFileSearchTool from azure.core.credentials_async import AsyncTokenCredential from azure.core.exceptions import ResourceNotFoundError -from pydantic import ValidationError from ._shared import AzureAISettings, create_text_format_config @@ -171,20 +171,20 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ client: AzureAIClient[MyOptions] = AzureAIClient(credential=credential) response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - try: - azure_ai_settings = AzureAISettings( - project_endpoint=project_endpoint, - model_deployment_name=model_deployment_name, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Azure AI settings.", ex) from ex + azure_ai_settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + project_endpoint=project_endpoint, + model_deployment_name=model_deployment_name, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) # If no project_client is provided, create one should_close_client = False if project_client is None: - if not azure_ai_settings.project_endpoint: + resolved_endpoint = azure_ai_settings.get("project_endpoint") + if not resolved_endpoint: raise ServiceInitializationError( "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." @@ -194,7 +194,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ if not credential: raise ServiceInitializationError("Azure credential is required when project_client is not provided.") project_client = AIProjectClient( - endpoint=azure_ai_settings.project_endpoint, + endpoint=resolved_endpoint, credential=credential, user_agent=AGENT_FRAMEWORK_USER_AGENT, ) @@ -212,7 +212,7 @@ class RawAzureAIClient(RawOpenAIResponsesClient[AzureAIClientOptionsT], Generic[ self.use_latest_version = use_latest_version self.project_client = project_client self.credential = credential - self.model_id = azure_ai_settings.model_deployment_name + self.model_id = azure_ai_settings.get("model_deployment_name") self.conversation_id = conversation_id # Track whether the application endpoint is used diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py index 0a6b571e40..cdf5cad5cf 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_project_provider.py @@ -16,6 +16,7 @@ from agent_framework import ( normalize_tools, ) from agent_framework._mcp import MCPTool +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( @@ -28,7 +29,6 @@ from azure.ai.projects.models import ( FunctionTool as AzureFunctionTool, ) from azure.core.credentials_async import AsyncTokenCredential -from pydantic import ValidationError from ._client import AzureAIClient, AzureAIProjectAgentOptions from ._shared import AzureAISettings, create_text_format_config, from_azure_ai_tools, to_azure_ai_tools @@ -123,21 +123,21 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]): Raises: ServiceInitializationError: If required parameters are missing or invalid. """ - try: - self._settings = AzureAISettings( - project_endpoint=project_endpoint, - model_deployment_name=model, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Azure AI settings.", ex) from ex + self._settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + project_endpoint=project_endpoint, + model_deployment_name=model, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) # Track whether we should close client connection self._should_close_client = False if project_client is None: - if not self._settings.project_endpoint: + resolved_endpoint = self._settings.get("project_endpoint") + if not resolved_endpoint: raise ServiceInitializationError( "Azure AI project endpoint is required. Set via 'project_endpoint' parameter " "or 'AZURE_AI_PROJECT_ENDPOINT' environment variable." @@ -147,7 +147,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]): raise ServiceInitializationError("Azure credential is required when project_client is not provided.") project_client = AIProjectClient( - endpoint=self._settings.project_endpoint, + endpoint=resolved_endpoint, credential=credential, user_agent=AGENT_FRAMEWORK_USER_AGENT, ) @@ -191,7 +191,7 @@ class AzureAIProjectAgentProvider(Generic[OptionsCoT]): ServiceInitializationError: If required parameters are missing. """ # Resolve model from parameter or environment variable - resolved_model = model or self._settings.model_deployment_name + resolved_model = model or self._settings.get("model_deployment_name") if not resolved_model: raise ServiceInitializationError( "Model deployment name is required. Provide 'model' parameter " diff --git a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py index 585fc9a9f5..81c113a1e4 100644 --- a/python/packages/azure-ai/agent_framework_azure_ai/_shared.py +++ b/python/packages/azure-ai/agent_framework_azure_ai/_shared.py @@ -2,14 +2,14 @@ from __future__ import annotations +import sys from collections.abc import Mapping, MutableMapping, Sequence -from typing import Any, ClassVar, cast +from typing import Any, cast from agent_framework import ( FunctionTool, get_logger, ) -from agent_framework._pydantic import AFBaseSettings from agent_framework.exceptions import ServiceInvalidRequestError from azure.ai.agents.models import ( CodeInterpreterToolDefinition, @@ -32,10 +32,15 @@ from azure.ai.projects.models import ( ) from pydantic import BaseModel +if sys.version_info >= (3, 11): + from typing import TypedDict # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + logger = get_logger("agent_framework.azure") -class AzureAISettings(AFBaseSettings): +class AzureAISettings(TypedDict, total=False): """Azure AI Project settings. The settings are first loaded from environment variables with the prefix 'AZURE_AI_'. @@ -70,10 +75,8 @@ class AzureAISettings(AFBaseSettings): settings = AzureAISettings(env_file_path="path/to/.env") """ - env_prefix: ClassVar[str] = "AZURE_AI_" - - project_endpoint: str | None = None - model_deployment_name: str | None = None + project_endpoint: str | None + model_deployment_name: str | None def _extract_project_connection_id(additional_properties: dict[str, Any] | None) -> str | None: diff --git a/python/packages/azure-ai/tests/test_agent_provider.py b/python/packages/azure-ai/tests/test_agent_provider.py index 30ef6fcf1c..b8755ed7d7 100644 --- a/python/packages/azure-ai/tests/test_agent_provider.py +++ b/python/packages/azure-ai/tests/test_agent_provider.py @@ -86,12 +86,9 @@ def test_provider_init_missing_endpoint_raises( mock_azure_credential: MagicMock, ) -> None: """Test AzureAIAgentsProvider raises error when endpoint is missing.""" - # Mock AzureAISettings to return None for project_endpoint - with patch("agent_framework_azure_ai._agent_provider.AzureAISettings") as mock_settings_class: - mock_settings = MagicMock() - mock_settings.project_endpoint = None - mock_settings.model_deployment_name = "test-model" - mock_settings_class.return_value = mock_settings + # Mock load_settings to return a dict with None for project_endpoint + with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} with pytest.raises(ServiceInitializationError) as exc_info: AzureAIAgentsProvider(credential=mock_azure_credential) @@ -270,11 +267,8 @@ async def test_create_agent_missing_model_raises( ) -> None: """Test that create_agent raises error when model is not specified.""" # Create provider with mocked settings that has no model - with patch("agent_framework_azure_ai._agent_provider.AzureAISettings") as mock_settings_class: - mock_settings = MagicMock() - mock_settings.project_endpoint = "https://test.com" - mock_settings.model_deployment_name = None # No model configured - mock_settings_class.return_value = mock_settings + with patch("agent_framework_azure_ai._agent_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None} provider = AzureAIAgentsProvider(agents_client=mock_agents_client) diff --git a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py index d0007841f2..b9df6e5042 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_agent_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_agent_client.py @@ -21,6 +21,7 @@ from agent_framework import ( tool, ) from agent_framework._serialization import SerializationMixin +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidRequestError from azure.ai.agents.models import ( AgentsNamedToolChoice, @@ -44,7 +45,7 @@ from azure.ai.agents.models import ( ) from azure.core.credentials_async import AsyncTokenCredential from azure.identity.aio import AzureCliCredential -from pydantic import BaseModel, Field, ValidationError +from pydantic import BaseModel, Field from agent_framework_azure_ai import AzureAIAgentClient, AzureAISettings @@ -67,7 +68,7 @@ def create_test_azure_ai_chat_client( ) -> AzureAIAgentClient: """Helper function to create AzureAIAgentClient instances for testing, bypassing normal validation.""" if azure_ai_settings is None: - azure_ai_settings = AzureAISettings(env_file_path="test.env") + azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", env_file_path="test.env") # Create client instance directly client = object.__new__(AzureAIAgentClient) @@ -78,7 +79,7 @@ def create_test_azure_ai_chat_client( client.agent_id = agent_id client.agent_name = agent_name client.agent_description = None - client.model_id = azure_ai_settings.model_deployment_name + client.model_id = azure_ai_settings.get("model_deployment_name") client.thread_id = thread_id client.should_cleanup_agent = should_cleanup_agent client._agent_created = False @@ -104,21 +105,23 @@ def create_test_azure_ai_chat_client( def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None: """Test AzureAISettings initialization.""" - settings = AzureAISettings() + settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - assert settings.project_endpoint == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - assert settings.model_deployment_name == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + assert settings["project_endpoint"] == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] + assert settings["model_deployment_name"] == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] def test_azure_ai_settings_init_with_explicit_values() -> None: """Test AzureAISettings initialization with explicit values.""" - settings = AzureAISettings( + settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", project_endpoint="https://custom-endpoint.com/", model_deployment_name="custom-model", ) - assert settings.project_endpoint == "https://custom-endpoint.com/" - assert settings.model_deployment_name == "custom-model" + assert settings["project_endpoint"] == "https://custom-endpoint.com/" + assert settings["model_deployment_name"] == "custom-model" def test_azure_ai_chat_client_init_with_client(mock_agents_client: MagicMock) -> None: @@ -138,33 +141,29 @@ def test_azure_ai_chat_client_init_auto_create_client( mock_agents_client: MagicMock, ) -> None: """Test AzureAIAgentClient initialization with auto-created agents_client.""" - azure_ai_settings = AzureAISettings(**azure_ai_unit_test_env) # type: ignore + azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", **azure_ai_unit_test_env) # type: ignore # Create client instance directly - client = object.__new__(AzureAIAgentClient) - client.agents_client = mock_agents_client - client.agent_id = None - client.thread_id = None - client._should_close_client = False # type: ignore - client.credential = None - client.model_id = azure_ai_settings.model_deployment_name - client.agent_name = None - client.additional_properties = {} - client.middleware = None + chat_client = object.__new__(AzureAIAgentClient) + chat_client.agents_client = mock_agents_client + chat_client.agent_id = None + chat_client.thread_id = None + chat_client._should_close_client = False # type: ignore + chat_client.credential = None + chat_client.model_id = azure_ai_settings.get("model_deployment_name") + chat_client.agent_name = None + chat_client.additional_properties = {} + chat_client.middleware = None - assert client.agents_client is mock_agents_client - assert client.agent_id is None + assert chat_client.agents_client is mock_agents_client + assert chat_client.agent_id is None def test_azure_ai_chat_client_init_missing_project_endpoint() -> None: """Test AzureAIAgentClient initialization when project_endpoint is missing and no agents_client provided.""" # Mock AzureAISettings to return settings with None project_endpoint - with patch("agent_framework_azure_ai._chat_client.AzureAISettings") as mock_settings: - mock_settings_instance = MagicMock() - mock_settings_instance.project_endpoint = None # This should trigger the error - mock_settings_instance.model_deployment_name = "test-model" - mock_settings_instance.agent_name = "test-agent" - mock_settings.return_value = mock_settings_instance + with patch("agent_framework_azure_ai._chat_client.load_settings") as mock_load_settings: + mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} with pytest.raises(ServiceInitializationError, match="project endpoint is required"): AzureAIAgentClient( @@ -179,12 +178,8 @@ def test_azure_ai_chat_client_init_missing_project_endpoint() -> None: def test_azure_ai_chat_client_init_missing_model_deployment_for_agent_creation() -> None: """Test AzureAIAgentClient initialization when model deployment is missing for agent creation.""" # Mock AzureAISettings to return settings with None model_deployment_name - with patch("agent_framework_azure_ai._chat_client.AzureAISettings") as mock_settings: - mock_settings_instance = MagicMock() - mock_settings_instance.project_endpoint = "https://test.com" - mock_settings_instance.model_deployment_name = None # This should trigger the error - mock_settings_instance.agent_name = "test-agent" - mock_settings.return_value = mock_settings_instance + with patch("agent_framework_azure_ai._chat_client.load_settings") as mock_load_settings: + mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None} with pytest.raises(ServiceInitializationError, match="model deployment name is required"): AzureAIAgentClient( @@ -210,20 +205,6 @@ def test_azure_ai_chat_client_init_missing_credential(azure_ai_unit_test_env: di ) -def test_azure_ai_chat_client_init_validation_error(mock_azure_credential: MagicMock) -> None: - """Test that ValidationError in AzureAISettings is properly handled.""" - with patch("agent_framework_azure_ai._chat_client.AzureAISettings") as mock_settings: - # Create a proper ValidationError with empty errors list and model dict - mock_settings.side_effect = ValidationError.from_exception_data("AzureAISettings", []) - - with pytest.raises(ServiceInitializationError, match="Failed to create Azure AI settings."): - AzureAIAgentClient( - project_endpoint="https://test.com", - model_deployment_name="test-model", - credential=mock_azure_credential, - ) - - def test_azure_ai_chat_client_from_dict() -> None: """Test from_settings class method.""" mock_agents_client = MagicMock() @@ -248,11 +229,15 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_temperature_and_ mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] ) -> None: """Test _get_agent_id_or_create with temperature and top_p in run_options.""" - azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) + azure_ai_settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ) client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) run_options = { - "model": azure_ai_settings.model_deployment_name, + "model": azure_ai_settings.get("model_deployment_name"), "temperature": 0.7, "top_p": 0.9, } @@ -284,13 +269,19 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_create_new( azure_ai_unit_test_env: dict[str, str], ) -> None: """Test _get_agent_id_or_create when creating a new agent.""" - azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) - client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) + azure_ai_settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ) + chat_client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) - agent_id = await client._get_agent_id_or_create(run_options={"model": azure_ai_settings.model_deployment_name}) # type: ignore + agent_id = await chat_client._get_agent_id_or_create( + run_options={"model": azure_ai_settings.get("model_deployment_name")} + ) # type: ignore assert agent_id == "test-agent-id" - assert client._agent_created + assert chat_client._agent_created async def test_azure_ai_chat_client_thread_management_through_public_api(mock_agents_client: MagicMock) -> None: @@ -547,14 +538,18 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_run_options( mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] ) -> None: """Test _get_agent_id_or_create with run_options containing tools and instructions.""" - azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) + azure_ai_settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ) client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) run_options = { "tools": [{"type": "function", "function": {"name": "test_tool"}}], "instructions": "Test instructions", "response_format": {"type": "json_object"}, - "model": azure_ai_settings.model_deployment_name, + "model": azure_ai_settings.get("model_deployment_name"), } agent_id = await client._get_agent_id_or_create(run_options) # type: ignore @@ -1134,13 +1129,19 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_agent_name( mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] ) -> None: """Test _get_agent_id_or_create uses default name when no agent_name set.""" - azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) + azure_ai_settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ) client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) # Ensure agent_name is None to test the default client.agent_name = None # type: ignore - agent_id = await client._get_agent_id_or_create(run_options={"model": azure_ai_settings.model_deployment_name}) # type: ignore + agent_id = await client._get_agent_id_or_create( + run_options={"model": azure_ai_settings.get("model_deployment_name")} + ) # type: ignore assert agent_id == "test-agent-id" # Verify create_agent was called with default "UnnamedAgent" @@ -1153,11 +1154,15 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_response_format( mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] ) -> None: """Test _get_agent_id_or_create with response_format in run_options.""" - azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) + azure_ai_settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ) client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) # Test with response_format in run_options - run_options = {"response_format": {"type": "json_object"}, "model": azure_ai_settings.model_deployment_name} + run_options = {"response_format": {"type": "json_object"}, "model": azure_ai_settings.get("model_deployment_name")} agent_id = await client._get_agent_id_or_create(run_options) # type: ignore @@ -1172,13 +1177,17 @@ async def test_azure_ai_chat_client_get_agent_id_or_create_with_tool_resources( mock_agents_client: MagicMock, azure_ai_unit_test_env: dict[str, str] ) -> None: """Test _get_agent_id_or_create with tool_resources in run_options.""" - azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) + azure_ai_settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ) client = create_test_azure_ai_chat_client(mock_agents_client, azure_ai_settings=azure_ai_settings) # Test with tool_resources in run_options run_options = { "tool_resources": {"vector_store_ids": ["vs-123"]}, - "model": azure_ai_settings.model_deployment_name, + "model": azure_ai_settings.get("model_deployment_name"), } agent_id = await client._get_agent_id_or_create(run_options) # type: ignore diff --git a/python/packages/azure-ai/tests/test_azure_ai_client.py b/python/packages/azure-ai/tests/test_azure_ai_client.py index b4e82cbefd..abcb2a5bda 100644 --- a/python/packages/azure-ai/tests/test_azure_ai_client.py +++ b/python/packages/azure-ai/tests/test_azure_ai_client.py @@ -20,6 +20,7 @@ from agent_framework import ( SupportsChatGetResponse, tool, ) +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError from azure.ai.projects.aio import AIProjectClient from azure.ai.projects.models import ( @@ -36,7 +37,7 @@ from azure.core.exceptions import ResourceNotFoundError from azure.identity.aio import AzureCliCredential from openai.types.responses.parsed_response import ParsedResponse from openai.types.responses.response import Response as OpenAIResponse -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from pydantic import BaseModel, ConfigDict, Field from pytest import fixture, param from agent_framework_azure_ai import AzureAIClient, AzureAISettings @@ -113,7 +114,7 @@ def create_test_azure_ai_client( ) -> AzureAIClient: """Helper function to create AzureAIClient instances for testing, bypassing normal validation.""" if azure_ai_settings is None: - azure_ai_settings = AzureAISettings(env_file_path="test.env") + azure_ai_settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_", env_file_path="test.env") # Create client instance directly client = object.__new__(AzureAIClient) @@ -125,7 +126,7 @@ def create_test_azure_ai_client( client.agent_version = agent_version client.agent_description = None client.use_latest_version = use_latest_version - client.model_id = azure_ai_settings.model_deployment_name + client.model_id = azure_ai_settings.get("model_deployment_name") client.conversation_id = conversation_id client._is_application_endpoint = False # type: ignore client._should_close_client = should_close_client # type: ignore @@ -143,28 +144,29 @@ def create_test_azure_ai_client( def test_azure_ai_settings_init(azure_ai_unit_test_env: dict[str, str]) -> None: """Test AzureAISettings initialization.""" - settings = AzureAISettings() + settings = load_settings(AzureAISettings, env_prefix="AZURE_AI_") - assert settings.project_endpoint == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - assert settings.model_deployment_name == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + assert settings["project_endpoint"] == azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] + assert settings["model_deployment_name"] == azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] def test_azure_ai_settings_init_with_explicit_values() -> None: """Test AzureAISettings initialization with explicit values.""" - settings = AzureAISettings( + settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", project_endpoint="https://custom-endpoint.com/", model_deployment_name="custom-model", ) - assert settings.project_endpoint == "https://custom-endpoint.com/" - assert settings.model_deployment_name == "custom-model" + assert settings["project_endpoint"] == "https://custom-endpoint.com/" + assert settings["model_deployment_name"] == "custom-model" def test_init_with_project_client(mock_project_client: MagicMock) -> None: """Test AzureAIClient initialization with existing project_client.""" - with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = None - mock_settings.return_value.model_deployment_name = "test-model" + with patch("agent_framework_azure_ai._client.load_settings") as mock_load_settings: + mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} client = AzureAIClient( project_client=mock_project_client, @@ -205,9 +207,8 @@ def test_init_auto_create_client( def test_init_missing_project_endpoint() -> None: """Test AzureAIClient initialization when project_endpoint is missing and no project_client provided.""" - with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = None - mock_settings.return_value.model_deployment_name = "test-model" + with patch("agent_framework_azure_ai._client.load_settings") as mock_load_settings: + mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} with pytest.raises(ServiceInitializationError, match="Azure AI project endpoint is required"): AzureAIClient(credential=MagicMock()) @@ -224,15 +225,6 @@ def test_init_missing_credential(azure_ai_unit_test_env: dict[str, str]) -> None ) -def test_init_validation_error(mock_azure_credential: MagicMock) -> None: - """Test that ValidationError in AzureAISettings is properly handled.""" - with patch("agent_framework_azure_ai._client.AzureAISettings") as mock_settings: - mock_settings.side_effect = ValidationError.from_exception_data("test", []) - - with pytest.raises(ServiceInitializationError, match="Failed to create Azure AI settings"): - AzureAIClient(credential=mock_azure_credential) - - async def test_get_agent_reference_or_create_existing_version( mock_project_client: MagicMock, ) -> None: @@ -259,7 +251,11 @@ async def test_get_agent_reference_or_create_new_agent( azure_ai_unit_test_env: dict[str, str], ) -> None: """Test _get_agent_reference_or_create when creating a new agent.""" - azure_ai_settings = AzureAISettings(model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"]) + azure_ai_settings = load_settings( + AzureAISettings, + env_prefix="AZURE_AI_", + model_deployment_name=azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + ) client = create_test_azure_ai_client( mock_project_client, agent_name="new-agent", azure_ai_settings=azure_ai_settings ) @@ -270,7 +266,7 @@ async def test_get_agent_reference_or_create_new_agent( mock_agent.version = "1.0" mock_project_client.agents.create_version = AsyncMock(return_value=mock_agent) - run_options = {"model": azure_ai_settings.model_deployment_name} + run_options = {"model": azure_ai_settings.get("model_deployment_name")} agent_ref = await client._get_agent_reference_or_create(run_options, None) # type: ignore assert agent_ref == {"name": "new-agent", "version": "1.0", "type": "agent_reference"} diff --git a/python/packages/azure-ai/tests/test_provider.py b/python/packages/azure-ai/tests/test_provider.py index 8d6cb1a29a..1b673d2ded 100644 --- a/python/packages/azure-ai/tests/test_provider.py +++ b/python/packages/azure-ai/tests/test_provider.py @@ -107,9 +107,8 @@ def test_provider_init_with_credential_and_endpoint( def test_provider_init_missing_endpoint() -> None: """Test AzureAIProjectAgentProvider initialization when endpoint is missing.""" - with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = None - mock_settings.return_value.model_deployment_name = "test-model" + with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = {"project_endpoint": None, "model_deployment_name": "test-model"} with pytest.raises(ServiceInitializationError, match="Azure AI project endpoint is required"): AzureAIProjectAgentProvider(credential=MagicMock()) @@ -130,9 +129,11 @@ async def test_provider_create_agent( azure_ai_unit_test_env: dict[str, str], ) -> None: """Test AzureAIProjectAgentProvider.create_agent method.""" - with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = { + "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + } provider = AzureAIProjectAgentProvider(project_client=mock_project_client) @@ -168,9 +169,11 @@ async def test_provider_create_agent_with_env_model( azure_ai_unit_test_env: dict[str, str], ) -> None: """Test AzureAIProjectAgentProvider.create_agent uses model from env var.""" - with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = { + "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + } provider = AzureAIProjectAgentProvider(project_client=mock_project_client) @@ -200,9 +203,8 @@ async def test_provider_create_agent_with_env_model( async def test_provider_create_agent_missing_model(mock_project_client: MagicMock) -> None: """Test AzureAIProjectAgentProvider.create_agent raises when model is missing.""" - with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = "https://test.com" - mock_settings.return_value.model_deployment_name = None + with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = {"project_endpoint": "https://test.com", "model_deployment_name": None} provider = AzureAIProjectAgentProvider(project_client=mock_project_client) @@ -215,9 +217,11 @@ async def test_provider_create_agent_with_rai_config( azure_ai_unit_test_env: dict[str, str], ) -> None: """Test AzureAIProjectAgentProvider.create_agent passes rai_config from default_options.""" - with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = { + "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + } provider = AzureAIProjectAgentProvider(project_client=mock_project_client) @@ -258,9 +262,11 @@ async def test_provider_create_agent_with_reasoning( azure_ai_unit_test_env: dict[str, str], ) -> None: """Test AzureAIProjectAgentProvider.create_agent passes reasoning from default_options.""" - with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = { + "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + } provider = AzureAIProjectAgentProvider(project_client=mock_project_client) @@ -465,9 +471,11 @@ async def test_provider_context_manager(mock_project_client: MagicMock) -> None: mock_client.close = AsyncMock() mock_ai_project_client.return_value = mock_client - with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = "https://test.com" - mock_settings.return_value.model_deployment_name = "test-model" + with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = { + "project_endpoint": "https://test.com", + "model_deployment_name": "test-model", + } async with AzureAIProjectAgentProvider(credential=MagicMock()) as provider: assert provider._project_client is mock_client # type: ignore @@ -494,9 +502,11 @@ async def test_provider_close_method(mock_project_client: MagicMock) -> None: mock_client.close = AsyncMock() mock_ai_project_client.return_value = mock_client - with patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings: - mock_settings.return_value.project_endpoint = "https://test.com" - mock_settings.return_value.model_deployment_name = "test-model" + with patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings: + mock_load_settings.return_value = { + "project_endpoint": "https://test.com", + "model_deployment_name": "test-model", + } provider = AzureAIProjectAgentProvider(credential=MagicMock()) await provider.close() @@ -581,12 +591,14 @@ async def test_provider_create_agent_with_mcp_tool( return [tools] with ( - patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings, + patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings, patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools, patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools), ): - mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + mock_load_settings.return_value = { + "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + } mock_to_azure_tools.return_value = [{"type": "function", "name": "mcp_function_1"}] provider = AzureAIProjectAgentProvider(project_client=mock_project_client) @@ -642,12 +654,14 @@ async def test_provider_create_agent_with_mcp_and_regular_tools( return [tools] with ( - patch("agent_framework_azure_ai._project_provider.AzureAISettings") as mock_settings, + patch("agent_framework_azure_ai._project_provider.load_settings") as mock_load_settings, patch("agent_framework_azure_ai._project_provider.to_azure_ai_tools") as mock_to_azure_tools, patch("agent_framework_azure_ai._project_provider.normalize_tools", side_effect=mock_normalize_tools), ): - mock_settings.return_value.project_endpoint = azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"] - mock_settings.return_value.model_deployment_name = azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"] + mock_load_settings.return_value = { + "project_endpoint": azure_ai_unit_test_env["AZURE_AI_PROJECT_ENDPOINT"], + "model_deployment_name": azure_ai_unit_test_env["AZURE_AI_MODEL_DEPLOYMENT_NAME"], + } mock_to_azure_tools.return_value = [] provider = AzureAIProjectAgentProvider(project_client=mock_project_client) diff --git a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py index 2b6deaf9bb..ba8573718e 100644 --- a/python/packages/bedrock/agent_framework_bedrock/_chat_client.py +++ b/python/packages/bedrock/agent_framework_bedrock/_chat_client.py @@ -30,13 +30,13 @@ from agent_framework import ( prepare_function_call_results, validate_tool_mode, ) -from agent_framework._pydantic import AFBaseSettings +from agent_framework._settings import SecretString, load_settings from agent_framework.exceptions import ServiceInitializationError, ServiceInvalidResponseError from agent_framework.observability import ChatTelemetryLayer from boto3.session import Session as Boto3Session from botocore.client import BaseClient from botocore.config import Config as BotoConfig -from pydantic import BaseModel, SecretStr, ValidationError +from pydantic import BaseModel if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -205,16 +205,14 @@ FINISH_REASON_MAP: dict[str, FinishReasonLiteral] = { } -class BedrockSettings(AFBaseSettings): +class BedrockSettings(TypedDict, total=False): """Bedrock configuration settings pulled from environment variables or .env files.""" - env_prefix: ClassVar[str] = "BEDROCK_" - - region: str = DEFAULT_REGION - chat_model_id: str | None = None - access_key: SecretStr | None = None - secret_key: SecretStr | None = None - session_token: SecretStr | None = None + region: str | None + chat_model_id: str | None + access_key: SecretString | None + secret_key: SecretString | None + session_token: SecretString | None class BedrockChatClient( @@ -280,24 +278,25 @@ class BedrockChatClient( client = BedrockChatClient[MyOptions](model_id="") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - try: - settings = BedrockSettings( - region=region, - chat_model_id=model_id, - access_key=access_key, # type: ignore[arg-type] - secret_key=secret_key, # type: ignore[arg-type] - session_token=session_token, # type: ignore[arg-type] - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to initialize Bedrock settings.", ex) from ex + settings = load_settings( + BedrockSettings, + env_prefix="BEDROCK_", + region=region, + chat_model_id=model_id, + access_key=access_key, + secret_key=secret_key, + session_token=session_token, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + if not settings.get("region"): + settings["region"] = DEFAULT_REGION if client is None: session = boto3_session or self._create_session(settings) client = session.client( "bedrock-runtime", - region_name=settings.region, + region_name=settings["region"], config=BotoConfig(user_agent_extra=AGENT_FRAMEWORK_USER_AGENT), ) @@ -307,17 +306,17 @@ class BedrockChatClient( **kwargs, ) self._bedrock_client = client - self.model_id = settings.chat_model_id - self.region = settings.region + self.model_id = settings["chat_model_id"] + self.region = settings["region"] @staticmethod def _create_session(settings: BedrockSettings) -> Boto3Session: - session_kwargs: dict[str, Any] = {"region_name": settings.region or DEFAULT_REGION} - if settings.access_key and settings.secret_key: - session_kwargs["aws_access_key_id"] = settings.access_key.get_secret_value() - session_kwargs["aws_secret_access_key"] = settings.secret_key.get_secret_value() - if settings.session_token: - session_kwargs["aws_session_token"] = settings.session_token.get_secret_value() + session_kwargs: dict[str, Any] = {"region_name": settings.get("region") or DEFAULT_REGION} + if settings.get("access_key") and settings.get("secret_key"): + session_kwargs["aws_access_key_id"] = settings["access_key"].get_secret_value() # type: ignore[union-attr] + session_kwargs["aws_secret_access_key"] = settings["secret_key"].get_secret_value() # type: ignore[union-attr] + if settings.get("session_token"): + session_kwargs["aws_session_token"] = settings["session_token"].get_secret_value() # type: ignore[union-attr] return Boto3Session(**session_kwargs) @override diff --git a/python/packages/bedrock/tests/test_bedrock_settings.py b/python/packages/bedrock/tests/test_bedrock_settings.py index 6a1956dd7c..8be9ca95e4 100644 --- a/python/packages/bedrock/tests/test_bedrock_settings.py +++ b/python/packages/bedrock/tests/test_bedrock_settings.py @@ -11,6 +11,7 @@ from agent_framework import ( FunctionTool, Message, ) +from agent_framework._settings import load_settings from pydantic import BaseModel from agent_framework_bedrock._chat_client import BedrockChatClient, BedrockSettings @@ -33,9 +34,9 @@ def _dummy_weather(location: str) -> str: # pragma: no cover - helper def test_settings_load_from_environment(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("BEDROCK_REGION", "us-west-2") monkeypatch.setenv("BEDROCK_CHAT_MODEL_ID", "anthropic.claude-v2") - settings = BedrockSettings() - assert settings.region == "us-west-2" - assert settings.chat_model_id == "anthropic.claude-v2" + settings = load_settings(BedrockSettings, env_prefix="BEDROCK_") + assert settings["region"] == "us-west-2" + assert settings["chat_model_id"] == "anthropic.claude-v2" def test_build_request_includes_tool_config() -> None: diff --git a/python/packages/claude/agent_framework_claude/_agent.py b/python/packages/claude/agent_framework_claude/_agent.py index 72f17f2742..50c5b06d0f 100644 --- a/python/packages/claude/agent_framework_claude/_agent.py +++ b/python/packages/claude/agent_framework_claude/_agent.py @@ -21,8 +21,9 @@ from agent_framework import ( get_logger, normalize_messages, ) +from agent_framework._settings import load_settings from agent_framework._types import normalize_tools -from agent_framework.exceptions import ServiceException, ServiceInitializationError +from agent_framework.exceptions import ServiceException from claude_agent_sdk import ( AssistantMessage, ClaudeSDKClient, @@ -34,7 +35,6 @@ from claude_agent_sdk import ( ClaudeAgentOptions as SDKOptions, ) from claude_agent_sdk.types import StreamEvent, TextBlock -from pydantic import ValidationError from ._settings import ClaudeAgentSettings @@ -273,19 +273,18 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]): self._mcp_servers: dict[str, Any] = opts.pop("mcp_servers", None) or {} # Load settings from environment and options - try: - self._settings = ClaudeAgentSettings( - cli_path=cli_path, - model=model, - cwd=cwd, - permission_mode=permission_mode, - max_turns=max_turns, - max_budget_usd=max_budget_usd, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Claude Agent settings.", ex) from ex + self._settings = load_settings( + ClaudeAgentSettings, + env_prefix="CLAUDE_AGENT_", + cli_path=cli_path, + model=model, + cwd=cwd, + permission_mode=permission_mode, + max_turns=max_turns, + max_budget_usd=max_budget_usd, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) # Separate built-in tools (strings) from custom tools (callables/FunctionTool) self._builtin_tools: list[str] = [] @@ -411,18 +410,18 @@ class ClaudeAgent(BaseAgent, Generic[OptionsT]): opts["resume"] = resume_session_id # Apply settings from environment - if self._settings.cli_path: - opts["cli_path"] = self._settings.cli_path - if self._settings.model: - opts["model"] = self._settings.model - if self._settings.cwd: - opts["cwd"] = self._settings.cwd - if self._settings.permission_mode: - opts["permission_mode"] = self._settings.permission_mode - if self._settings.max_turns: - opts["max_turns"] = self._settings.max_turns - if self._settings.max_budget_usd: - opts["max_budget_usd"] = self._settings.max_budget_usd + if self._settings["cli_path"]: + opts["cli_path"] = self._settings["cli_path"] + if self._settings["model"]: + opts["model"] = self._settings["model"] + if self._settings["cwd"]: + opts["cwd"] = self._settings["cwd"] + if self._settings["permission_mode"]: + opts["permission_mode"] = self._settings["permission_mode"] + if self._settings["max_turns"]: + opts["max_turns"] = self._settings["max_turns"] + if self._settings["max_budget_usd"]: + opts["max_budget_usd"] = self._settings["max_budget_usd"] # Apply default options for key, value in self._default_options.items(): diff --git a/python/packages/claude/agent_framework_claude/_settings.py b/python/packages/claude/agent_framework_claude/_settings.py index b01e189cc8..cccc0fe373 100644 --- a/python/packages/claude/agent_framework_claude/_settings.py +++ b/python/packages/claude/agent_framework_claude/_settings.py @@ -1,51 +1,29 @@ # Copyright (c) Microsoft. All rights reserved. -from typing import ClassVar - -from agent_framework._pydantic import AFBaseSettings +from typing import TypedDict __all__ = ["ClaudeAgentSettings"] -class ClaudeAgentSettings(AFBaseSettings): +class ClaudeAgentSettings(TypedDict, total=False): """Claude Agent settings. The settings are first loaded from environment variables with the prefix 'CLAUDE_AGENT_'. If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. If the settings are not found in the .env file, the settings - are ignored; however, validation will fail alerting that the settings are missing. + with the encoding 'utf-8'. - Keyword Args: + Keys: cli_path: The path to Claude CLI executable. model: The model to use (sonnet, opus, haiku). cwd: The working directory for Claude CLI. permission_mode: Permission mode (default, acceptEdits, plan, bypassPermissions). max_turns: Maximum number of conversation turns. max_budget_usd: Maximum budget in USD. - env_file_path: If provided, the .env settings are read from this file path location. - env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. - - Examples: - .. code-block:: python - - from agent_framework.anthropic import ClaudeAgentSettings - - # Using environment variables - # Set CLAUDE_AGENT_MODEL=sonnet - # CLAUDE_AGENT_PERMISSION_MODE=default - - # Or passing parameters directly - settings = ClaudeAgentSettings(model="sonnet") - - # Or loading from a .env file - settings = ClaudeAgentSettings(env_file_path="path/to/.env") """ - env_prefix: ClassVar[str] = "CLAUDE_AGENT_" - - cli_path: str | None = None - model: str | None = None - cwd: str | None = None - permission_mode: str | None = None - max_turns: int | None = None - max_budget_usd: float | None = None + cli_path: str | None + model: str | None + cwd: str | None + permission_mode: str | None + max_turns: int | None + max_budget_usd: float | None diff --git a/python/packages/claude/tests/test_claude_agent.py b/python/packages/claude/tests/test_claude_agent.py index b3d39d82ab..cc28df81c3 100644 --- a/python/packages/claude/tests/test_claude_agent.py +++ b/python/packages/claude/tests/test_claude_agent.py @@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest from agent_framework import AgentResponseUpdate, AgentThread, Content, Message, tool +from agent_framework._settings import load_settings from agent_framework_claude import ClaudeAgent, ClaudeAgentOptions, ClaudeAgentSettings from agent_framework_claude._agent import TOOLS_MCP_SERVER_NAME @@ -15,23 +16,21 @@ from agent_framework_claude._agent import TOOLS_MCP_SERVER_NAME class TestClaudeAgentSettings: """Tests for ClaudeAgentSettings.""" - def test_env_prefix(self) -> None: - """Test that env_prefix is correctly set.""" - assert ClaudeAgentSettings.env_prefix == "CLAUDE_AGENT_" - def test_default_values(self) -> None: """Test default values are None.""" - settings = ClaudeAgentSettings() - assert settings.cli_path is None - assert settings.model is None - assert settings.cwd is None - assert settings.permission_mode is None - assert settings.max_turns is None - assert settings.max_budget_usd is None + settings = load_settings(ClaudeAgentSettings, env_prefix="CLAUDE_AGENT_") + assert settings["cli_path"] is None + assert settings["model"] is None + assert settings["cwd"] is None + assert settings["permission_mode"] is None + assert settings["max_turns"] is None + assert settings["max_budget_usd"] is None def test_explicit_values(self) -> None: """Test explicit values override defaults.""" - settings = ClaudeAgentSettings( + settings = load_settings( + ClaudeAgentSettings, + env_prefix="CLAUDE_AGENT_", cli_path="/usr/local/bin/claude", model="sonnet", cwd="/home/user/project", @@ -39,20 +38,20 @@ class TestClaudeAgentSettings: max_turns=10, max_budget_usd=5.0, ) - assert settings.cli_path == "/usr/local/bin/claude" - assert settings.model == "sonnet" - assert settings.cwd == "/home/user/project" - assert settings.permission_mode == "default" - assert settings.max_turns == 10 - assert settings.max_budget_usd == 5.0 + assert settings["cli_path"] == "/usr/local/bin/claude" + assert settings["model"] == "sonnet" + assert settings["cwd"] == "/home/user/project" + assert settings["permission_mode"] == "default" + assert settings["max_turns"] == 10 + assert settings["max_budget_usd"] == 5.0 def test_env_variable_loading(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test loading from environment variables.""" monkeypatch.setenv("CLAUDE_AGENT_MODEL", "opus") monkeypatch.setenv("CLAUDE_AGENT_MAX_TURNS", "20") - settings = ClaudeAgentSettings() - assert settings.model == "opus" - assert settings.max_turns == 20 + settings = load_settings(ClaudeAgentSettings, env_prefix="CLAUDE_AGENT_") + assert settings["model"] == "opus" + assert settings["max_turns"] == 20 # region Test ClaudeAgent Initialization @@ -95,9 +94,9 @@ class TestClaudeAgentInit: "max_turns": 10, } agent = ClaudeAgent(default_options=options) - assert agent._settings.model == "sonnet" # type: ignore[reportPrivateUsage] - assert agent._settings.permission_mode == "default" # type: ignore[reportPrivateUsage] - assert agent._settings.max_turns == 10 # type: ignore[reportPrivateUsage] + assert agent._settings["model"] == "sonnet" # type: ignore[reportPrivateUsage] + assert agent._settings["permission_mode"] == "default" # type: ignore[reportPrivateUsage] + assert agent._settings["max_turns"] == 10 # type: ignore[reportPrivateUsage] def test_with_function_tool(self) -> None: """Test agent with function tool.""" @@ -620,13 +619,13 @@ class TestClaudeAgentPermissions: def test_default_permission_mode(self) -> None: """Test default permission mode.""" agent = ClaudeAgent() - assert agent._settings.permission_mode is None # type: ignore[reportPrivateUsage] + assert agent._settings["permission_mode"] is None # type: ignore[reportPrivateUsage] def test_permission_mode_from_settings(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test permission mode from environment settings.""" monkeypatch.setenv("CLAUDE_AGENT_PERMISSION_MODE", "acceptEdits") - settings = ClaudeAgentSettings() - assert settings.permission_mode == "acceptEdits" + settings = load_settings(ClaudeAgentSettings, env_prefix="CLAUDE_AGENT_") + assert settings["permission_mode"] == "acceptEdits" def test_permission_mode_in_options(self) -> None: """Test permission mode in options.""" @@ -634,7 +633,7 @@ class TestClaudeAgentPermissions: "permission_mode": "bypassPermissions", } agent = ClaudeAgent(default_options=options) - assert agent._settings.permission_mode == "bypassPermissions" # type: ignore[reportPrivateUsage] + assert agent._settings["permission_mode"] == "bypassPermissions" # type: ignore[reportPrivateUsage] # region Test ClaudeAgent Error Handling diff --git a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py index 7a2567e48f..c0e395b210 100644 --- a/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py +++ b/python/packages/copilotstudio/agent_framework_copilotstudio/_agent.py @@ -3,7 +3,7 @@ from __future__ import annotations from collections.abc import AsyncIterable, Awaitable, Sequence -from typing import Any, ClassVar, Literal, overload +from typing import Any, Literal, TypedDict, overload from agent_framework import ( AgentMiddlewareTypes, @@ -17,23 +17,21 @@ from agent_framework import ( ResponseStream, normalize_messages, ) -from agent_framework._pydantic import AFBaseSettings +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceException, ServiceInitializationError from microsoft_agents.copilotstudio.client import AgentType, ConnectionSettings, CopilotClient, PowerPlatformCloud -from pydantic import ValidationError from ._acquire_token import acquire_token -class CopilotStudioSettings(AFBaseSettings): +class CopilotStudioSettings(TypedDict, total=False): """Copilot Studio model settings. The settings are first loaded from environment variables with the prefix 'COPILOTSTUDIOAGENT__'. If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. If the settings are not found in the .env file, the settings - are ignored; however, validation will fail alerting that the settings are missing. + with the encoding 'utf-8'. - Keyword Args: + Keys: environmentid: Environment ID of environment with the Copilot Studio App. Can be set via environment variable COPILOTSTUDIOAGENT__ENVIRONMENTID. schemaname: The agent identifier or schema name of the Copilot to use. @@ -42,32 +40,12 @@ class CopilotStudioSettings(AFBaseSettings): Can be set via environment variable COPILOTSTUDIOAGENT__AGENTAPPID. tenantid: The tenant ID of the App Registration used to login. Can be set via environment variable COPILOTSTUDIOAGENT__TENANTID. - env_file_path: If provided, the .env settings are read from this file path location. - env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. - - Examples: - .. code-block:: python - - from agent_framework_copilotstudio import CopilotStudioSettings - - # Using environment variables - # Set COPILOTSTUDIOAGENT__ENVIRONMENTID=env-123 - # Set COPILOTSTUDIOAGENT__SCHEMANAME=my-agent - settings = CopilotStudioSettings() - - # Or passing parameters directly - settings = CopilotStudioSettings(environmentid="env-123", schemaname="my-agent") - - # Or loading from a .env file - settings = CopilotStudioSettings(env_file_path="path/to/.env") """ - env_prefix: ClassVar[str] = "COPILOTSTUDIOAGENT__" - - environmentid: str | None = None - schemaname: str | None = None - agentappid: str | None = None - tenantid: str | None = None + environmentid: str | None + schemaname: str | None + agentappid: str | None + tenantid: str | None class CopilotStudioAgent(BaseAgent): @@ -144,54 +122,53 @@ class CopilotStudioAgent(BaseAgent): middleware=middleware, ) if not client: - try: - copilot_studio_settings = CopilotStudioSettings( - environmentid=environment_id, - schemaname=agent_identifier, - agentappid=client_id, - tenantid=tenant_id, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Copilot Studio settings.", ex) from ex + copilot_studio_settings = load_settings( + CopilotStudioSettings, + env_prefix="COPILOTSTUDIOAGENT__", + environmentid=environment_id, + schemaname=agent_identifier, + agentappid=client_id, + tenantid=tenant_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) if not settings: - if not copilot_studio_settings.environmentid: + if not copilot_studio_settings["environmentid"]: raise ServiceInitializationError( "Copilot Studio environment ID is required. Set via 'environment_id' parameter " "or 'COPILOTSTUDIOAGENT__ENVIRONMENTID' environment variable." ) - if not copilot_studio_settings.schemaname: + if not copilot_studio_settings["schemaname"]: raise ServiceInitializationError( "Copilot Studio agent identifier/schema name is required. Set via 'agent_identifier' parameter " "or 'COPILOTSTUDIOAGENT__SCHEMANAME' environment variable." ) settings = ConnectionSettings( - environment_id=copilot_studio_settings.environmentid, - agent_identifier=copilot_studio_settings.schemaname, + environment_id=copilot_studio_settings["environmentid"], + agent_identifier=copilot_studio_settings["schemaname"], cloud=cloud, copilot_agent_type=agent_type, custom_power_platform_cloud=custom_power_platform_cloud, ) if not token: - if not copilot_studio_settings.agentappid: + if not copilot_studio_settings["agentappid"]: raise ServiceInitializationError( "Copilot Studio client ID is required. Set via 'client_id' parameter " "or 'COPILOTSTUDIOAGENT__AGENTAPPID' environment variable." ) - if not copilot_studio_settings.tenantid: + if not copilot_studio_settings["tenantid"]: raise ServiceInitializationError( "Copilot Studio tenant ID is required. Set via 'tenant_id' parameter " "or 'COPILOTSTUDIOAGENT__TENANTID' environment variable." ) token = acquire_token( - client_id=copilot_studio_settings.agentappid, - tenant_id=copilot_studio_settings.tenantid, + client_id=copilot_studio_settings["agentappid"], + tenant_id=copilot_studio_settings["tenantid"], username=username, token_cache=token_cache, scopes=scopes, diff --git a/python/packages/copilotstudio/tests/test_copilot_agent.py b/python/packages/copilotstudio/tests/test_copilot_agent.py index 6172f871d3..fb16f151f3 100644 --- a/python/packages/copilotstudio/tests/test_copilot_agent.py +++ b/python/packages/copilotstudio/tests/test_copilot_agent.py @@ -38,49 +38,57 @@ class TestCopilotStudioAgent: return MagicMock(spec=CopilotClient) @patch("agent_framework_copilotstudio._acquire_token.acquire_token") - @patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") - def test_init_missing_environment_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None: + @patch("agent_framework_copilotstudio._agent.load_settings") + def test_init_missing_environment_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None: mock_acquire_token.return_value = "fake-token" - mock_settings.return_value.environmentid = None - mock_settings.return_value.schemaname = "test-bot" - mock_settings.return_value.tenantid = "test-tenant" - mock_settings.return_value.agentappid = "test-client" + mock_load_settings.return_value = { + "environmentid": None, + "schemaname": "test-bot", + "tenantid": "test-tenant", + "agentappid": "test-client", + } with pytest.raises(ServiceInitializationError, match="environment ID is required"): CopilotStudioAgent() @patch("agent_framework_copilotstudio._acquire_token.acquire_token") - @patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") - def test_init_missing_bot_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None: + @patch("agent_framework_copilotstudio._agent.load_settings") + def test_init_missing_bot_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None: mock_acquire_token.return_value = "fake-token" - mock_settings.return_value.environmentid = "test-env" - mock_settings.return_value.schemaname = None - mock_settings.return_value.tenantid = "test-tenant" - mock_settings.return_value.agentappid = "test-client" + mock_load_settings.return_value = { + "environmentid": "test-env", + "schemaname": None, + "tenantid": "test-tenant", + "agentappid": "test-client", + } with pytest.raises(ServiceInitializationError, match="agent identifier"): CopilotStudioAgent() @patch("agent_framework_copilotstudio._acquire_token.acquire_token") - @patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") - def test_init_missing_tenant_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None: + @patch("agent_framework_copilotstudio._agent.load_settings") + def test_init_missing_tenant_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None: mock_acquire_token.return_value = "fake-token" - mock_settings.return_value.environmentid = "test-env" - mock_settings.return_value.schemaname = "test-bot" - mock_settings.return_value.tenantid = None - mock_settings.return_value.agentappid = "test-client" + mock_load_settings.return_value = { + "environmentid": "test-env", + "schemaname": "test-bot", + "tenantid": None, + "agentappid": "test-client", + } with pytest.raises(ServiceInitializationError, match="tenant ID is required"): CopilotStudioAgent() @patch("agent_framework_copilotstudio._acquire_token.acquire_token") - @patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") - def test_init_missing_client_id(self, mock_settings: MagicMock, mock_acquire_token: MagicMock) -> None: + @patch("agent_framework_copilotstudio._agent.load_settings") + def test_init_missing_client_id(self, mock_load_settings: MagicMock, mock_acquire_token: MagicMock) -> None: mock_acquire_token.return_value = "fake-token" - mock_settings.return_value.environmentid = "test-env" - mock_settings.return_value.schemaname = "test-bot" - mock_settings.return_value.tenantid = "test-tenant" - mock_settings.return_value.agentappid = None + mock_load_settings.return_value = { + "environmentid": "test-env", + "schemaname": "test-bot", + "tenantid": "test-tenant", + "agentappid": None, + } with pytest.raises(ServiceInitializationError, match="client ID is required"): CopilotStudioAgent() @@ -93,11 +101,13 @@ class TestCopilotStudioAgent: @patch("agent_framework_copilotstudio._acquire_token.acquire_token") def test_init_empty_environment_id(self, mock_acquire_token: MagicMock) -> None: mock_acquire_token.return_value = "fake-token" - with patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") as mock_settings: - mock_settings.return_value.environmentid = "" - mock_settings.return_value.schemaname = "test-bot" - mock_settings.return_value.tenantid = "test-tenant" - mock_settings.return_value.agentappid = "test-client" + with patch("agent_framework_copilotstudio._agent.load_settings") as mock_load_settings: + mock_load_settings.return_value = { + "environmentid": "", + "schemaname": "test-bot", + "tenantid": "test-tenant", + "agentappid": "test-client", + } with pytest.raises(ServiceInitializationError, match="environment ID is required"): CopilotStudioAgent() @@ -105,11 +115,13 @@ class TestCopilotStudioAgent: @patch("agent_framework_copilotstudio._acquire_token.acquire_token") def test_init_empty_schema_name(self, mock_acquire_token: MagicMock) -> None: mock_acquire_token.return_value = "fake-token" - with patch("agent_framework_copilotstudio._agent.CopilotStudioSettings") as mock_settings: - mock_settings.return_value.environmentid = "test-env" - mock_settings.return_value.schemaname = "" - mock_settings.return_value.tenantid = "test-tenant" - mock_settings.return_value.agentappid = "test-client" + with patch("agent_framework_copilotstudio._agent.load_settings") as mock_load_settings: + mock_load_settings.return_value = { + "environmentid": "test-env", + "schemaname": "", + "tenantid": "test-tenant", + "agentappid": "test-client", + } with pytest.raises(ServiceInitializationError, match="agent identifier"): CopilotStudioAgent() diff --git a/python/packages/core/agent_framework/_pydantic.py b/python/packages/core/agent_framework/_pydantic.py deleted file mode 100644 index b8652745b3..0000000000 --- a/python/packages/core/agent_framework/_pydantic.py +++ /dev/null @@ -1,70 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - - -from __future__ import annotations - -from typing import Annotated, Any, ClassVar, TypeVar - -from pydantic import Field, UrlConstraints -from pydantic.networks import AnyUrl -from pydantic_settings import BaseSettings, SettingsConfigDict - -HTTPsUrl = Annotated[AnyUrl, UrlConstraints(max_length=2083, allowed_schemes=["https"])] - -__all__ = ["AFBaseSettings", "HTTPsUrl"] - - -SettingsT = TypeVar("SettingsT", bound="AFBaseSettings") - - -class AFBaseSettings(BaseSettings): - """Base class for all settings classes in the Agent Framework. - - A subclass creates it's fields and overrides the env_prefix class variable - with the prefix for the environment variables. - - In the case where a value is specified for the same Settings field in multiple ways, - the selected value is determined as follows (in descending order of priority): - - Arguments passed to the Settings class initializer. - - Environment variables, e.g. my_prefix_special_function as described above. - - Variables loaded from a dotenv (.env) file. - - Variables loaded from the secrets directory. - - The default field values for the Settings model. - """ - - env_prefix: ClassVar[str] = "" - env_file_path: str | None = Field(default=None, exclude=True) - env_file_encoding: str | None = Field(default="utf-8", exclude=True) - - model_config = SettingsConfigDict( - extra="ignore", - case_sensitive=False, - ) - - def __init__( - self, - **kwargs: Any, - ) -> None: - """Initialize the settings class.""" - # Remove any None values from the kwargs so that defaults are used. - kwargs = {k: v for k, v in kwargs.items() if v is not None} - super().__init__(**kwargs) - - def __new__(cls: type[SettingsT], *args: Any, **kwargs: Any) -> SettingsT: - """Override the __new__ method to set the env_prefix.""" - # for both, if supplied but None, set to default - if "env_file_encoding" in kwargs and kwargs["env_file_encoding"] is not None: - env_file_encoding = kwargs["env_file_encoding"] - else: - env_file_encoding = "utf-8" - if "env_file_path" in kwargs and kwargs["env_file_path"] is not None: - env_file_path = kwargs["env_file_path"] - else: - env_file_path = ".env" - cls.model_config.update( # type: ignore - env_prefix=cls.env_prefix, - env_file=env_file_path, - env_file_encoding=env_file_encoding, - ) - cls.model_rebuild() - return super().__new__(cls) # type: ignore[return-value] diff --git a/python/packages/core/agent_framework/_settings.py b/python/packages/core/agent_framework/_settings.py new file mode 100644 index 0000000000..d378688d55 --- /dev/null +++ b/python/packages/core/agent_framework/_settings.py @@ -0,0 +1,262 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Generic settings loader with environment variable resolution. + +This module provides a ``load_settings()`` function that populates a ``TypedDict`` +from environment variables, ``.env`` files, and explicit overrides. It replaces +the previous pydantic-settings-based ``AFBaseSettings`` with a lighter-weight, +function-based approach that has no pydantic-settings dependency. + +Usage:: + + class MySettings(TypedDict, total=False): + api_key: str | None # optional — resolves to None if not set + model_id: str | None # optional by default + + + # Make model_id required at call time: + settings = load_settings( + MySettings, + env_prefix="MY_APP_", + required_fields=["model_id"], + model_id="gpt-4", + ) + settings["api_key"] # type-checked dict access + settings["model_id"] # str | None per type, but guaranteed not None at runtime +""" + +from __future__ import annotations + +import os +import sys +from collections.abc import Callable, Sequence +from contextlib import suppress +from typing import Any, Union, get_args, get_origin, get_type_hints + +from dotenv import load_dotenv + +from .exceptions import SettingNotFoundError + +if sys.version_info >= (3, 13): + from typing import TypeVar # type: ignore # pragma: no cover +else: + from typing_extensions import TypeVar # type: ignore # pragma: no cover + +__all__ = ["SecretString", "load_settings"] + +SettingsT = TypeVar("SettingsT", default=dict[str, Any]) + + +class SecretString(str): + """A string subclass that masks its value in repr() to prevent accidental exposure. + + SecretString behaves exactly like a regular string in all operations, + but its repr() shows '**********' instead of the actual value. + This helps prevent secrets from being accidentally logged or displayed. + + It also provides a ``get_secret_value()`` method for backward compatibility + with code that previously used ``pydantic.SecretStr``. + + Example: + ```python + api_key = SecretString("sk-secret-key") + print(api_key) # sk-secret-key (normal string behavior) + print(repr(api_key)) # SecretString('**********') + print(f"Key: {api_key}") # Key: sk-secret-key + print(api_key.get_secret_value()) # sk-secret-key + ``` + """ + + def __repr__(self) -> str: + """Return a masked representation to prevent secret exposure.""" + return "SecretString('**********')" + + def get_secret_value(self) -> str: + """Return the underlying string value. + + Provided for backward compatibility with ``pydantic.SecretStr``. + Since SecretString *is* a str, this simply returns ``str(self)``. + """ + return str(self) + + +def _coerce_value(value: str, target_type: type) -> Any: + """Coerce a string value to the target type.""" + origin = get_origin(target_type) + args = get_args(target_type) + + # Handle Union types (e.g., str | None) — try each non-None arm + if origin is type(None): + return None + + if args and type(None) in args: + for arg in args: + if arg is not type(None): + with suppress(ValueError, TypeError): + return _coerce_value(value, arg) + return value + + # Handle SecretString + if target_type is SecretString or (isinstance(target_type, type) and issubclass(target_type, SecretString)): + return SecretString(value) + + # Handle basic types + if target_type is str: + return value + if target_type is int: + return int(value) + if target_type is float: + return float(value) + if target_type is bool: + return value.lower() in ("true", "1", "yes", "on") + + return value + + +def _check_override_type(value: Any, field_type: type, field_name: str) -> None: + """Validate that *value* is compatible with *field_type*. + + Raises ``ServiceInitializationError`` when the override is clearly + incompatible (e.g. a ``dict`` passed where ``str`` is expected). + Callable values and ``None`` are always accepted. + """ + if value is None: + return + + # Callables are always allowed (e.g. lazy token providers) + if callable(value) and not isinstance(value, (str, bytes)): + return + + # Collect the concrete types that *field_type* allows + origin = get_origin(field_type) + args = get_args(field_type) + + allowed: tuple[type, ...] + if origin is Union or origin is type(int | str): + allowed = tuple(a for a in args if isinstance(a, type) and a is not type(None)) + # If any arm is a Callable, allow anything callable + if any(get_origin(a) is Callable or a is Callable for a in args): + return + elif isinstance(field_type, type): + allowed = (field_type,) + else: + return # complex / unknown annotation — skip check + + if not allowed: + return + + if not isinstance(value, allowed): + # Allow str for SecretString fields (will be coerced) + if isinstance(value, str) and any(isinstance(a, type) and issubclass(a, str) for a in allowed): + return + # Allow int for float fields (standard numeric promotion) + if isinstance(value, int) and float in allowed: + return + + from .exceptions import ServiceInitializationError + + allowed_names = ", ".join(t.__name__ for t in allowed) + raise ServiceInitializationError( + f"Invalid type for setting '{field_name}': expected {allowed_names}, got {type(value).__name__}." + ) + + +def load_settings( + settings_type: type[SettingsT], + *, + env_prefix: str = "", + env_file_path: str | None = None, + env_file_encoding: str | None = None, + required_fields: Sequence[str] | None = None, + **overrides: Any, +) -> SettingsT: + """Load settings from environment variables, a ``.env`` file, and explicit overrides. + + The *settings_type* must be a ``TypedDict`` subclass. Values are resolved in + this order (highest priority first): + + 1. Explicit keyword *overrides* (``None`` values are filtered out). + 2. Environment variables (````). + 3. A ``.env`` file (loaded via ``python-dotenv``; existing env vars take precedence). + 4. Default values — fields with class-level defaults on the TypedDict, or + ``None`` for optional fields. + + Fields listed in *required_fields* are validated after resolution. If any + required field resolves to ``None``, a ``SettingNotFoundError`` is raised. + This allows callers to decide which fields are required based on runtime + context (e.g. ``endpoint`` is only required when no pre-built client is + provided). + + Args: + settings_type: A ``TypedDict`` class describing the settings schema. + env_prefix: Prefix for environment variable lookup (e.g. ``"OPENAI_"``). + env_file_path: Path to ``.env`` file. Defaults to ``".env"`` when omitted. + env_file_encoding: Encoding for reading the ``.env`` file. Defaults to ``"utf-8"``. + required_fields: Field names that must resolve to a non-``None`` value. + **overrides: Field values. ``None`` values are ignored so that callers can + forward optional parameters without masking env-var / default resolution. + + Returns: + A populated dict matching *settings_type*. + + Raises: + SettingNotFoundError: If a required field could not be resolved from any source. + ServiceInitializationError: If an override value has an incompatible type. + """ + encoding = env_file_encoding or "utf-8" + + # Load .env file if it exists (existing env vars take precedence by default) + env_path = env_file_path or ".env" + if os.path.isfile(env_path): + load_dotenv(dotenv_path=env_path, encoding=encoding) + + # Filter out None overrides so defaults / env vars are preserved + overrides = {k: v for k, v in overrides.items() if v is not None} + + # Get field type hints from the TypedDict + hints = get_type_hints(settings_type) + required: set[str] = set(required_fields) if required_fields else set() + + result: dict[str, Any] = {} + for field_name, field_type in hints.items(): + # 1. Explicit override wins + if field_name in overrides: + override_value = overrides[field_name] + _check_override_type(override_value, field_type, field_name) + # Coerce plain str → SecretString if the annotation expects it + if isinstance(override_value, str) and not isinstance(override_value, SecretString): + with suppress(ValueError, TypeError): + coerced = _coerce_value(override_value, field_type) + if isinstance(coerced, SecretString): + override_value = coerced + result[field_name] = override_value + continue + + # 2. Environment variable + env_var_name = f"{env_prefix}{field_name.upper()}" + env_value = os.getenv(env_var_name) + if env_value is not None: + try: + result[field_name] = _coerce_value(env_value, field_type) + except (ValueError, TypeError): + result[field_name] = env_value + continue + + # 3. Default from TypedDict class-level defaults, or None for optional fields + if hasattr(settings_type, field_name): + result[field_name] = getattr(settings_type, field_name) + else: + result[field_name] = None + + # Validate required fields after all resolution + if required: + for field_name in required: + if result.get(field_name) is None: + env_var_name = f"{env_prefix}{field_name.upper()}" + raise SettingNotFoundError( + f"Required setting '{field_name}' was not provided. " + f"Set it via the '{field_name}' parameter or the " + f"'{env_var_name}' environment variable." + ) + + return result # type: ignore[return-value] diff --git a/python/packages/core/agent_framework/_workflows/__init__.py b/python/packages/core/agent_framework/_workflows/__init__.py index e573c51e23..3eb65335c9 100644 --- a/python/packages/core/agent_framework/_workflows/__init__.py +++ b/python/packages/core/agent_framework/_workflows/__init__.py @@ -13,7 +13,6 @@ from ._checkpoint import ( InMemoryCheckpointStorage, WorkflowCheckpoint, ) -from ._checkpoint_summary import WorkflowCheckpointSummary, get_checkpoint_summary from ._const import ( DEFAULT_MAX_ITERATIONS, ) @@ -107,7 +106,6 @@ __all__ = [ "WorkflowBuilder", "WorkflowCheckpoint", "WorkflowCheckpointException", - "WorkflowCheckpointSummary", "WorkflowContext", "WorkflowConvergenceException", "WorkflowErrorDetails", @@ -124,7 +122,6 @@ __all__ = [ "WorkflowViz", "create_edge_runner", "executor", - "get_checkpoint_summary", "handler", "resolve_agent_id", "response_handler", diff --git a/python/packages/core/agent_framework/_workflows/_agent.py b/python/packages/core/agent_framework/_workflows/_agent.py index 962da2bcd4..06b3bbb613 100644 --- a/python/packages/core/agent_framework/_workflows/_agent.py +++ b/python/packages/core/agent_framework/_workflows/_agent.py @@ -718,7 +718,7 @@ class WorkflowAgent(BaseAgent): def _extract_contents(self, data: Any) -> list[Content]: """Recursively extract Content from workflow output data.""" if isinstance(data, list): - return [c for item in data for c in self._extract_contents(item)] + return [c for item in data for c in self._extract_contents(item)] # type: ignore if isinstance(data, Content): return [data] # type: ignore[redundant-cast] if isinstance(data, str): diff --git a/python/packages/core/agent_framework/_workflows/_agent_executor.py b/python/packages/core/agent_framework/_workflows/_agent_executor.py index 0923e5c93c..8290391fb9 100644 --- a/python/packages/core/agent_framework/_workflows/_agent_executor.py +++ b/python/packages/core/agent_framework/_workflows/_agent_executor.py @@ -13,9 +13,7 @@ from .._agents import SupportsAgentRun from .._threads import AgentThread from .._types import AgentResponse, AgentResponseUpdate, Message from ._agent_utils import resolve_agent_id -from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value from ._const import WORKFLOW_RUN_KWARGS_KEY -from ._conversation_state import encode_chat_messages from ._executor import Executor, handler from ._message_utils import normalize_messages_input from ._request_info_mixin import response_handler @@ -232,11 +230,11 @@ class AgentExecutor(Executor): serialized_thread = await self._agent_thread.serialize() return { - "cache": encode_chat_messages(self._cache), - "full_conversation": encode_chat_messages(self._full_conversation), + "cache": self._cache, + "full_conversation": self._full_conversation, "agent_thread": serialized_thread, - "pending_agent_requests": encode_checkpoint_value(self._pending_agent_requests), - "pending_responses_to_agent": encode_checkpoint_value(self._pending_responses_to_agent), + "pending_agent_requests": self._pending_agent_requests, + "pending_responses_to_agent": self._pending_responses_to_agent, } @override @@ -246,27 +244,11 @@ class AgentExecutor(Executor): Args: state: Checkpoint data dict """ - from ._conversation_state import decode_chat_messages - cache_payload = state.get("cache") - if cache_payload: - try: - self._cache = decode_chat_messages(cache_payload) - except Exception as exc: - logger.warning("Failed to restore cache: %s", exc) - self._cache = [] - else: - self._cache = [] + self._cache = cache_payload or [] full_conversation_payload = state.get("full_conversation") - if full_conversation_payload: - try: - self._full_conversation = decode_chat_messages(full_conversation_payload) - except Exception as exc: - logger.warning("Failed to restore full conversation: %s", exc) - self._full_conversation = [] - else: - self._full_conversation = [] + self._full_conversation = full_conversation_payload or [] thread_payload = state.get("agent_thread") if thread_payload: @@ -282,11 +264,11 @@ class AgentExecutor(Executor): pending_requests_payload = state.get("pending_agent_requests") if pending_requests_payload: - self._pending_agent_requests = decode_checkpoint_value(pending_requests_payload) + self._pending_agent_requests = pending_requests_payload pending_responses_payload = state.get("pending_responses_to_agent") if pending_responses_payload: - self._pending_responses_to_agent = decode_checkpoint_value(pending_responses_payload) + self._pending_responses_to_agent = pending_responses_payload def reset(self) -> None: """Reset the internal cache of the executor.""" diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint.py b/python/packages/core/agent_framework/_workflows/_checkpoint.py index 0334ee3893..4d3f87b89e 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint.py @@ -3,18 +3,29 @@ from __future__ import annotations import asyncio +import copy import json import logging import os import uuid from collections.abc import Mapping -from dataclasses import asdict, dataclass, field +from dataclasses import dataclass, field, fields from datetime import datetime, timezone from pathlib import Path -from typing import Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol, TypeAlias + +from ._exceptions import WorkflowCheckpointException logger = logging.getLogger(__name__) +if TYPE_CHECKING: + from ._events import WorkflowEvent + from ._runner_context import WorkflowMessage + +# Type alias for checkpoint IDs in case we want to change the +# underlying type in the future (e.g., to UUID or a custom class) +CheckpointID: TypeAlias = str + @dataclass(slots=True) class WorkflowCheckpoint: @@ -23,15 +34,31 @@ class WorkflowCheckpoint: Checkpoints capture the full execution state of a workflow at a specific point, enabling workflows to be paused and resumed. + Note that a checkpoint is not tied to a specific workflow instance, but rather to + a workflow definition (identified by workflow_name and graph_signature_hash). Thus, + the ID of the workflow instance that created the checkpoint is not included in the + checkpoint data. This allows checkpoints to be shared and restored across different + workflow instances of the same workflow definition. + Attributes: + workflow_name: Name of the workflow this checkpoint belongs to. This acts as a + logical grouping for checkpoints and can be used to filter checkpoints by + workflow. Workflows with the same name are expected to have compatible graph + structures for checkpointing. + graph_signature_hash: Hash of the workflow graph topology to validate checkpoint + compatibility during restore checkpoint_id: Unique identifier for this checkpoint - workflow_id: Identifier of the workflow this checkpoint belongs to + previous_checkpoint_id: ID of the previous checkpoint in the chain, if any. This + allows chaining checkpoints together to form a history of workflow states. timestamp: ISO 8601 timestamp when checkpoint was created messages: Messages exchanged between executors state: Committed workflow state including user data and executor states. - This contains only committed state; pending state changes are not - included in checkpoints. Executor states are stored under the - reserved key '_executor_state'. + This contains only committed state; pending state changes are not + included in checkpoints. Executor states are stored under the + reserved key '_executor_state'. + pending_request_info_events: Any pending request info events that have not + yet been processed at the time of checkpointing. This allows the workflow + to resume with the correct pending events after a restore. iteration_count: Current iteration number when checkpoint was created metadata: Additional metadata (e.g., superstep info, graph signature) version: Checkpoint format version @@ -41,14 +68,17 @@ class WorkflowCheckpoint: See State class documentation for details on reserved keys. """ - checkpoint_id: str = field(default_factory=lambda: str(uuid.uuid4())) - workflow_id: str = "" + workflow_name: str + graph_signature_hash: str + + checkpoint_id: CheckpointID = field(default_factory=lambda: str(uuid.uuid4())) + previous_checkpoint_id: CheckpointID | None = None timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat()) # Core workflow state - messages: dict[str, list[dict[str, Any]]] = field(default_factory=dict) # type: ignore[misc] + messages: dict[str, list[WorkflowMessage]] = field(default_factory=dict) # type: ignore[misc] state: dict[str, Any] = field(default_factory=dict) # type: ignore[misc] - pending_request_info_events: dict[str, dict[str, Any]] = field(default_factory=dict) # type: ignore[misc] + pending_request_info_events: dict[str, WorkflowEvent[Any]] = field(default_factory=dict) # type: ignore[misc] # Runtime state iteration_count: int = 0 @@ -58,34 +88,104 @@ class WorkflowCheckpoint: version: str = "1.0" def to_dict(self) -> dict[str, Any]: - return asdict(self) + """Convert the WorkflowCheckpoint to a dictionary. + + Notes: + 1. This method does not recursively convert nested dataclasses to dicts. + 2. This is a shallow conversion. The resulting dict will contain the same + references to nested objects as the original dataclass. + """ + return {f.name: getattr(self, f.name) for f in fields(self)} @classmethod def from_dict(cls, data: Mapping[str, Any]) -> WorkflowCheckpoint: - return cls(**data) + """Create a WorkflowCheckpoint from a dictionary. + + Args: + data: Dictionary containing checkpoint fields. + + Returns: + A new WorkflowCheckpoint instance. + + Raises: + WorkflowCheckpointException: If required fields are missing. + """ + try: + return cls(**data) + except Exception as ex: + raise WorkflowCheckpointException(f"Failed to create WorkflowCheckpoint from dict: {ex}") from ex class CheckpointStorage(Protocol): """Protocol for checkpoint storage backends.""" - async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str: - """Save a checkpoint and return its ID.""" + async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID: + """Save a checkpoint and return its ID. + + Args: + checkpoint: The WorkflowCheckpoint object to save. + + Returns: + The unique ID of the saved checkpoint. + """ ... - async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: - """Load a checkpoint by ID.""" + async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint: + """Load a checkpoint by ID. + + Args: + checkpoint_id: The unique ID of the checkpoint to load. + + Returns: + The WorkflowCheckpoint object corresponding to the given ID. + + Raises: + WorkflowCheckpointException: If no checkpoint with the given ID exists. + """ ... - async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]: - """List checkpoint IDs. If workflow_id is provided, filter by that workflow.""" + async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]: + """List checkpoint objects for a given workflow name. + + Args: + workflow_name: The name of the workflow to list checkpoints for. + + Returns: + A list of WorkflowCheckpoint objects for the specified workflow name. + """ ... - async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]: - """List checkpoint objects. If workflow_id is provided, filter by that workflow.""" + async def delete(self, checkpoint_id: CheckpointID) -> bool: + """Delete a checkpoint by ID. + + Args: + checkpoint_id: The unique ID of the checkpoint to delete. + + Returns: + True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists. + """ ... - async def delete_checkpoint(self, checkpoint_id: str) -> bool: - """Delete a checkpoint by ID.""" + async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None: + """Get the latest checkpoint for a given workflow name. + + Args: + workflow_name: The name of the workflow to get the latest checkpoint for. + + Returns: + The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist. + """ + ... + + async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]: + """List checkpoint IDs for a given workflow name. + + Args: + workflow_name: The name of the workflow to list checkpoint IDs for. + + Returns: + A list of checkpoint IDs for the specified workflow name. + """ ... @@ -94,34 +194,27 @@ class InMemoryCheckpointStorage: def __init__(self) -> None: """Initialize the memory storage.""" - self._checkpoints: dict[str, WorkflowCheckpoint] = {} + self._checkpoints: dict[CheckpointID, WorkflowCheckpoint] = {} - async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str: + async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID: """Save a checkpoint and return its ID.""" - self._checkpoints[checkpoint.checkpoint_id] = checkpoint + self._checkpoints[checkpoint.checkpoint_id] = copy.deepcopy(checkpoint) logger.debug(f"Saved checkpoint {checkpoint.checkpoint_id} to memory") return checkpoint.checkpoint_id - async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: + async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint: """Load a checkpoint by ID.""" checkpoint = self._checkpoints.get(checkpoint_id) if checkpoint: logger.debug(f"Loaded checkpoint {checkpoint_id} from memory") - return checkpoint + return checkpoint + raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}") - async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]: - """List checkpoint IDs. If workflow_id is provided, filter by that workflow.""" - if workflow_id is None: - return list(self._checkpoints.keys()) - return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_id == workflow_id] + async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]: + """List checkpoint objects for a given workflow name.""" + return [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name] - async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]: - """List checkpoint objects. If workflow_id is provided, filter by that workflow.""" - if workflow_id is None: - return list(self._checkpoints.values()) - return [cp for cp in self._checkpoints.values() if cp.workflow_id == workflow_id] - - async def delete_checkpoint(self, checkpoint_id: str) -> bool: + async def delete(self, checkpoint_id: CheckpointID) -> bool: """Delete a checkpoint by ID.""" if checkpoint_id in self._checkpoints: del self._checkpoints[checkpoint_id] @@ -129,9 +222,31 @@ class InMemoryCheckpointStorage: return True return False + async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None: + """Get the latest checkpoint for a given workflow name.""" + checkpoints = [cp for cp in self._checkpoints.values() if cp.workflow_name == workflow_name] + if not checkpoints: + return None + latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp)) + logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}") + return latest_checkpoint + + async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]: + """List checkpoint IDs. If workflow_id is provided, filter by that workflow.""" + return [cp.checkpoint_id for cp in self._checkpoints.values() if cp.workflow_name == workflow_name] + class FileCheckpointStorage: - """File-based checkpoint storage for persistence.""" + """File-based checkpoint storage for persistence. + + This storage implements a hybrid approach where the checkpoint metadata and structure are + stored in JSON format, while the actual state data (which may contain complex Python objects) + is serialized using pickle and embedded as base64-encoded strings within the JSON. This allows + for human-readable checkpoint files while preserving the ability to store complex Python objects. + + SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints + from trusted sources. Loading a malicious checkpoint file can execute arbitrary code. + """ def __init__(self, storage_path: str | Path): """Initialize the file storage.""" @@ -139,15 +254,45 @@ class FileCheckpointStorage: self.storage_path.mkdir(parents=True, exist_ok=True) logger.info(f"Initialized file checkpoint storage at {self.storage_path}") - async def save_checkpoint(self, checkpoint: WorkflowCheckpoint) -> str: - """Save a checkpoint and return its ID.""" - file_path = self.storage_path / f"{checkpoint.checkpoint_id}.json" - checkpoint_dict = asdict(checkpoint) + def _validate_file_path(self, checkpoint_id: CheckpointID) -> Path: + """Validate that a checkpoint ID resolves to a path within the storage directory. + + This can prevent someone from crafting a checkpoint ID that points to an arbitrary + file on the filesystem. + + Args: + checkpoint_id: The checkpoint ID to validate. + + Returns: + The validated file path. + + Raises: + WorkflowCheckpointException: If the checkpoint ID would resolve outside the storage directory. + """ + file_path = (self.storage_path / f"{checkpoint_id}.json").resolve() + if not file_path.is_relative_to(self.storage_path.resolve()): + raise WorkflowCheckpointException(f"Invalid checkpoint ID: {checkpoint_id}") + return file_path + + async def save(self, checkpoint: WorkflowCheckpoint) -> CheckpointID: + """Save a checkpoint and return its ID. + + Args: + checkpoint: The WorkflowCheckpoint object to save. + + Returns: + The unique ID of the saved checkpoint. + """ + from ._checkpoint_encoding import encode_checkpoint_value + + file_path = self._validate_file_path(checkpoint.checkpoint_id) + checkpoint_dict = checkpoint.to_dict() + encoded_checkpoint = encode_checkpoint_value(checkpoint_dict) def _write_atomic() -> None: tmp_path = file_path.with_suffix(".json.tmp") with open(tmp_path, "w") as f: - json.dump(checkpoint_dict, f, indent=2, ensure_ascii=False) + json.dump(encoded_checkpoint, f, indent=2, ensure_ascii=False) os.replace(tmp_path, file_path) await asyncio.to_thread(_write_atomic) @@ -155,60 +300,78 @@ class FileCheckpointStorage: logger.info(f"Saved checkpoint {checkpoint.checkpoint_id} to {file_path}") return checkpoint.checkpoint_id - async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: - """Load a checkpoint by ID.""" - file_path = self.storage_path / f"{checkpoint_id}.json" + async def load(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint: + """Load a checkpoint by ID. + + Args: + checkpoint_id: The unique ID of the checkpoint to load. + + Returns: + The WorkflowCheckpoint object corresponding to the given ID. + + Raises: + WorkflowCheckpointException: If no checkpoint with the given ID exists, + or if checkpoint decoding fails. + """ + file_path = self._validate_file_path(checkpoint_id) if not file_path.exists(): - return None + raise WorkflowCheckpointException(f"No checkpoint found with ID {checkpoint_id}") def _read() -> dict[str, Any]: with open(file_path) as f: return json.load(f) # type: ignore[no-any-return] - checkpoint_dict = await asyncio.to_thread(_read) + encoded_checkpoint = await asyncio.to_thread(_read) - checkpoint = WorkflowCheckpoint(**checkpoint_dict) + from ._checkpoint_encoding import CheckpointDecodingError, decode_checkpoint_value + + try: + decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint) + except CheckpointDecodingError as exc: + raise WorkflowCheckpointException(f"Failed to decode checkpoint {checkpoint_id}: {exc}") from exc + checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict) logger.info(f"Loaded checkpoint {checkpoint_id} from {file_path}") return checkpoint - async def list_checkpoint_ids(self, workflow_id: str | None = None) -> list[str]: - """List checkpoint IDs. If workflow_id is provided, filter by that workflow.""" + async def list_checkpoints(self, *, workflow_name: str) -> list[WorkflowCheckpoint]: + """List checkpoint objects for a given workflow name. - def _list_ids() -> list[str]: - checkpoint_ids: list[str] = [] - for file_path in self.storage_path.glob("*.json"): - try: - with open(file_path) as f: - data = json.load(f) - if workflow_id is None or data.get("workflow_id") == workflow_id: - checkpoint_ids.append(data.get("checkpoint_id", file_path.stem)) - except Exception as e: - logger.warning(f"Failed to read checkpoint file {file_path}: {e}") - return checkpoint_ids + Args: + workflow_name: The name of the workflow to list checkpoints for. - return await asyncio.to_thread(_list_ids) - - async def list_checkpoints(self, workflow_id: str | None = None) -> list[WorkflowCheckpoint]: - """List checkpoint objects. If workflow_id is provided, filter by that workflow.""" + Returns: + A list of WorkflowCheckpoint objects for the specified workflow name. + """ def _list_checkpoints() -> list[WorkflowCheckpoint]: checkpoints: list[WorkflowCheckpoint] = [] for file_path in self.storage_path.glob("*.json"): try: with open(file_path) as f: - data = json.load(f) - if workflow_id is None or data.get("workflow_id") == workflow_id: - checkpoints.append(WorkflowCheckpoint.from_dict(data)) + encoded_checkpoint = json.load(f) + from ._checkpoint_encoding import decode_checkpoint_value + + decoded_checkpoint_dict = decode_checkpoint_value(encoded_checkpoint) + checkpoint = WorkflowCheckpoint.from_dict(decoded_checkpoint_dict) + if checkpoint.workflow_name == workflow_name: + checkpoints.append(checkpoint) except Exception as e: logger.warning(f"Failed to read checkpoint file {file_path}: {e}") return checkpoints return await asyncio.to_thread(_list_checkpoints) - async def delete_checkpoint(self, checkpoint_id: str) -> bool: - """Delete a checkpoint by ID.""" - file_path = self.storage_path / f"{checkpoint_id}.json" + async def delete(self, checkpoint_id: CheckpointID) -> bool: + """Delete a checkpoint by ID. + + Args: + checkpoint_id: The unique ID of the checkpoint to delete. + + Returns: + True if the checkpoint was successfully deleted, False if no checkpoint with the given ID exists. + """ + file_path = self._validate_file_path(checkpoint_id) def _delete() -> bool: if file_path.exists(): @@ -218,3 +381,43 @@ class FileCheckpointStorage: return False return await asyncio.to_thread(_delete) + + async def get_latest(self, *, workflow_name: str) -> WorkflowCheckpoint | None: + """Get the latest checkpoint for a given workflow name. + + Args: + workflow_name: The name of the workflow to get the latest checkpoint for. + + Returns: + The latest WorkflowCheckpoint object for the specified workflow name, or None if no checkpoints exist. + """ + checkpoints = await self.list_checkpoints(workflow_name=workflow_name) + if not checkpoints: + return None + latest_checkpoint = max(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp)) + logger.debug(f"Latest checkpoint for workflow {workflow_name} is {latest_checkpoint.checkpoint_id}") + return latest_checkpoint + + async def list_checkpoint_ids(self, *, workflow_name: str) -> list[CheckpointID]: + """List checkpoint IDs for a given workflow name. + + Args: + workflow_name: The name of the workflow to list checkpoint IDs for. + + Returns: + A list of checkpoint IDs for the specified workflow name. + """ + + def _list_ids() -> list[CheckpointID]: + checkpoint_ids: list[CheckpointID] = [] + for file_path in self.storage_path.glob("*.json"): + try: + with open(file_path) as f: + data = json.load(f) + if data.get("workflow_name") == workflow_name: + checkpoint_ids.append(data.get("checkpoint_id", file_path.stem)) + except Exception as e: + logger.warning(f"Failed to read checkpoint file {file_path}: {e}") + return checkpoint_ids + + return await asyncio.to_thread(_list_ids) diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py index 644744c798..524f291c5e 100644 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py +++ b/python/packages/core/agent_framework/_workflows/_checkpoint_encoding.py @@ -2,269 +2,169 @@ from __future__ import annotations -import contextlib -import importlib -import logging -import sys -from dataclasses import fields, is_dataclass -from typing import Any, cast +import base64 +import pickle # nosec # noqa: S403 +from typing import Any -# Checkpoint serialization helpers -MODEL_MARKER = "__af_model__" -DATACLASS_MARKER = "__af_dataclass__" +from agent_framework import get_logger -# Guards to prevent runaway recursion while encoding arbitrary user data -_MAX_ENCODE_DEPTH = 100 -_CYCLE_SENTINEL = "" +"""Checkpoint encoding using JSON structure with pickle+base64 for arbitrary data. -logger = logging.getLogger(__name__) +This hybrid approach provides: +- Human-readable JSON structure for debugging and inspection of primitives and collections +- Full Python object fidelity via pickle for data values (non-JSON-native types) +- Base64 encoding to embed binary pickle data in JSON strings + +SECURITY WARNING: Checkpoints use pickle for data serialization. Only load checkpoints +from trusted sources. Loading a malicious checkpoint file can execute arbitrary code. +""" + + +logger = get_logger(__name__) + +# Marker to identify pickled values in serialized JSON +_PICKLE_MARKER = "__pickled__" +_TYPE_MARKER = "__type__" + +# Types that are natively JSON-serializable and don't need pickling +_JSON_NATIVE_TYPES = (str, int, float, bool, type(None)) + + +class CheckpointDecodingError(Exception): + """Raised when checkpoint decoding fails due to type mismatch or corruption.""" def encode_checkpoint_value(value: Any) -> Any: - """Recursively encode values into JSON-serializable structures. + """Encode a Python value for checkpoint storage. - - Objects exposing to_dict/to_json -> { MODEL_MARKER: "module:Class", value: encoded } - - dataclass instances -> { DATACLASS_MARKER: "module:Class", value: {field: encoded} } - - dict -> encode keys as str and values recursively - - list/tuple/set -> list of encoded items - - other -> returned as-is if already JSON-serializable + JSON-native types (str, int, float, bool, None) pass through unchanged. + Collections (dict, list) are recursed with their values encoded. + All other types (dataclasses, custom objects, datetime, etc.) are pickled + and stored as base64-encoded strings. - Includes cycle and depth protection to avoid infinite recursion. + Args: + value: Any Python value to encode. + + Returns: + A JSON-serializable representation of the value. """ - - def _enc(v: Any, stack: set[int], depth: int) -> Any: - # Depth guard - if depth > _MAX_ENCODE_DEPTH: - logger.debug(f"Max encode depth reached at depth={depth} for type={type(v)}") - return "" - - # Structured model handling (objects exposing to_dict/to_json) - if _supports_model_protocol(v): - cls = cast(type[Any], type(v)) # type: ignore - try: - if hasattr(v, "to_dict") and callable(getattr(v, "to_dict", None)): - raw = v.to_dict() # type: ignore[attr-defined] - strategy = "to_dict" - elif hasattr(v, "to_json") and callable(getattr(v, "to_json", None)): - serialized = v.to_json() # type: ignore[attr-defined] - if isinstance(serialized, (bytes, bytearray)): - try: - serialized = serialized.decode() - except Exception: - serialized = serialized.decode(errors="replace") - raw = serialized - strategy = "to_json" - else: - raise AttributeError("Structured model lacks serialization hooks") - return { - MODEL_MARKER: f"{cls.__module__}:{cls.__name__}", - "strategy": strategy, - "value": _enc(raw, stack, depth + 1), - } - except Exception as exc: # best-effort fallback - logger.debug(f"Structured model serialization failed for {cls}: {exc}") - return str(v) - - # Dataclasses (instances only) - if is_dataclass(v) and not isinstance(v, type): - oid = id(v) - if oid in stack: - logger.debug("Cycle detected while encoding dataclass instance") - return _CYCLE_SENTINEL - stack.add(oid) - try: - # type(v) already narrows sufficiently; cast was redundant - dc_cls: type[Any] = type(v) - field_values: dict[str, Any] = {} - for f in fields(v): - field_values[f.name] = _enc(getattr(v, f.name), stack, depth + 1) - return { - DATACLASS_MARKER: f"{dc_cls.__module__}:{dc_cls.__name__}", - "value": field_values, - } - finally: - stack.remove(oid) - - # Collections - if isinstance(v, dict): - v_dict = cast("dict[object, object]", v) - oid = id(v_dict) - if oid in stack: - logger.debug("Cycle detected while encoding dict") - return _CYCLE_SENTINEL - stack.add(oid) - try: - json_dict: dict[str, Any] = {} - for k_any, val_any in v_dict.items(): # type: ignore[assignment] - k_str: str = str(k_any) - json_dict[k_str] = _enc(val_any, stack, depth + 1) - return json_dict - finally: - stack.remove(oid) - - if isinstance(v, (list, tuple, set)): - iterable_v = cast("list[object] | tuple[object, ...] | set[object]", v) - oid = id(iterable_v) - if oid in stack: - logger.debug("Cycle detected while encoding iterable") - return _CYCLE_SENTINEL - stack.add(oid) - try: - seq: list[object] = list(iterable_v) - encoded_list: list[Any] = [] - for item in seq: - encoded_list.append(_enc(item, stack, depth + 1)) - return encoded_list - finally: - stack.remove(oid) - - # Primitives (or unknown objects): ensure JSON-serializable - if isinstance(v, (str, int, float, bool)) or v is None: - return v - # Fallback: stringify unknown objects to avoid JSON serialization errors - try: - return str(v) - except Exception: - return f"<{type(v).__name__}>" - - return _enc(value, set(), 0) + return _encode(value) def decode_checkpoint_value(value: Any) -> Any: - """Recursively decode values previously encoded by encode_checkpoint_value.""" + """Decode a value from checkpoint storage. + + Reverses the encoding performed by encode_checkpoint_value. + Pickled values (identified by _PICKLE_MARKER) are decoded and unpickled. + + WARNING: Only call this with trusted data. Pickle can execute + arbitrary code during deserialization. The post-unpickle type verification + detects accidental corruption or type mismatches, but cannot prevent + arbitrary code execution from malicious pickle payloads. + + Args: + value: A JSON-deserialized value from checkpoint storage. + + Returns: + The original Python value. + + Raises: + CheckpointDecodingError: If the unpickled object's type doesn't match + the recorded type, indicating corruption, or if the base64/pickle + data is malformed. + """ + return _decode(value) + + +def _encode(value: Any) -> Any: + """Recursively encode a value for JSON storage.""" + # JSON-native types pass through + if isinstance(value, _JSON_NATIVE_TYPES): + return value + + # Recursively encode dict values (keys become strings) if isinstance(value, dict): - value_dict = cast(dict[str, Any], value) # encoded form always uses string keys - # Structured model marker handling - if MODEL_MARKER in value_dict and "value" in value_dict: - type_key: str | None = value_dict.get(MODEL_MARKER) # type: ignore[assignment] - strategy: str | None = value_dict.get("strategy") # type: ignore[assignment] - raw_encoded: Any = value_dict.get("value") - decoded_payload = decode_checkpoint_value(raw_encoded) - if isinstance(type_key, str): - try: - cls = _import_qualified_name(type_key) - except Exception as exc: - logger.debug(f"Failed to import structured model {type_key}: {exc}") - cls = None + return {str(k): _encode(v) for k, v in value.items()} # type: ignore - if cls is not None: - # Verify the class actually supports the model protocol - if not _class_supports_model_protocol(cls): - logger.debug(f"Class {type_key} does not support model protocol; returning raw value") - return decoded_payload - if strategy == "to_dict" and hasattr(cls, "from_dict"): - with contextlib.suppress(Exception): - return cls.from_dict(decoded_payload) - if strategy == "to_json" and hasattr(cls, "from_json"): - if isinstance(decoded_payload, (str, bytes, bytearray)): - with contextlib.suppress(Exception): - return cls.from_json(decoded_payload) - if isinstance(decoded_payload, dict) and hasattr(cls, "from_dict"): - with contextlib.suppress(Exception): - return cls.from_dict(decoded_payload) - return decoded_payload - # Dataclass marker handling - if DATACLASS_MARKER in value_dict and "value" in value_dict: - type_key_dc: str | None = value_dict.get(DATACLASS_MARKER) # type: ignore[assignment] - raw_dc: Any = value_dict.get("value") - decoded_raw = decode_checkpoint_value(raw_dc) - if isinstance(type_key_dc, str): - try: - module_name, class_name = type_key_dc.split(":", 1) - module = sys.modules.get(module_name) - if module is None: - module = importlib.import_module(module_name) - cls_dc: Any = getattr(module, class_name) - # Verify the class is actually a dataclass type (not an instance) - if not isinstance(cls_dc, type) or not is_dataclass(cls_dc): - logger.debug(f"Class {type_key_dc} is not a dataclass type; returning raw value") - return decoded_raw - constructed = _instantiate_checkpoint_dataclass(cls_dc, decoded_raw) - if constructed is not None: - return constructed - except Exception as exc: - logger.debug(f"Failed to decode dataclass {type_key_dc}: {exc}; returning raw value") - return decoded_raw - - # Regular dict: decode recursively - decoded: dict[str, Any] = {} - for k_any, v_any in value_dict.items(): - decoded[k_any] = decode_checkpoint_value(v_any) - return decoded + # Recursively encode list items (lists are JSON-native collections) if isinstance(value, list): - # After isinstance check, treat value as list[Any] for decoding - value_list: list[Any] = value # type: ignore[assignment] - return [decode_checkpoint_value(v_any) for v_any in value_list] + return [_encode(item) for item in value] # type: ignore + + # Everything else (tuples, sets, dataclasses, custom objects, etc.): pickle and base64 encode + return { + _PICKLE_MARKER: _pickle_to_base64(value), + _TYPE_MARKER: _type_to_key(type(value)), # type: ignore + } + + +def _decode(value: Any) -> Any: + """Recursively decode a value from JSON storage.""" + # JSON-native types pass through + if isinstance(value, _JSON_NATIVE_TYPES): + return value + + # Handle encoded dicts + if isinstance(value, dict): + # Pickled value: decode, unpickle, and verify type + if _PICKLE_MARKER in value and _TYPE_MARKER in value: + obj = _base64_to_unpickle(value[_PICKLE_MARKER]) # type: ignore + _verify_type(obj, value.get(_TYPE_MARKER)) # type: ignore + return obj + + # Regular dict: decode values recursively + return {k: _decode(v) for k, v in value.items()} # type: ignore + + # Handle encoded lists + if isinstance(value, list): + return [_decode(item) for item in value] # type: ignore + return value -def _class_supports_model_protocol(cls: type[Any]) -> bool: - """Check if a class type supports the model serialization protocol. +def _verify_type(obj: Any, expected_type_key: str) -> None: + """Verify that an unpickled object matches its recorded type. - Checks for pairs of serialization/deserialization methods: - - to_dict/from_dict - - to_json/from_json + This is a post-deserialization integrity check that detects accidental + corruption or type mismatches. It does not prevent arbitrary code execution + from malicious pickle payloads, since ``pickle.loads()`` has already + executed by the time this function is called. + + Args: + obj: The unpickled object. + expected_type_key: The recorded type key (module:qualname format). + + Raises: + CheckpointDecodingError: If the types don't match. """ - has_to_dict = hasattr(cls, "to_dict") and callable(getattr(cls, "to_dict", None)) - has_from_dict = hasattr(cls, "from_dict") and callable(getattr(cls, "from_dict", None)) - - has_to_json = hasattr(cls, "to_json") and callable(getattr(cls, "to_json", None)) - has_from_json = hasattr(cls, "from_json") and callable(getattr(cls, "from_json", None)) - - return (has_to_dict and has_from_dict) or (has_to_json and has_from_json) + actual_type_key = _type_to_key(type(obj)) # type: ignore + if actual_type_key != expected_type_key: + raise CheckpointDecodingError( + f"Type mismatch during checkpoint decoding: " + f"expected '{expected_type_key}', got '{actual_type_key}'. " + f"The checkpoint may be corrupted or tampered with." + ) -def _supports_model_protocol(obj: object) -> bool: - """Detect objects that expose dictionary serialization hooks.""" +def _pickle_to_base64(value: Any) -> str: + """Pickle a value and encode as base64 string.""" + pickled = pickle.dumps(value, protocol=pickle.HIGHEST_PROTOCOL) + return base64.b64encode(pickled).decode("ascii") + + +def _base64_to_unpickle(encoded: str) -> Any: + """Decode base64 string and unpickle. + + Raises: + CheckpointDecodingError: If the base64 data is corrupted or the pickle + format is incompatible. + """ try: - obj_type: type[Any] = type(obj) - except Exception: - return False - - return _class_supports_model_protocol(obj_type) - - -def _import_qualified_name(qualname: str) -> type[Any] | None: - if ":" not in qualname: - return None - module_name, class_name = qualname.split(":", 1) - module = sys.modules.get(module_name) - if module is None: - module = importlib.import_module(module_name) - attr: Any = module - for part in class_name.split("."): - attr = getattr(attr, part) - return attr if isinstance(attr, type) else None - - -def _instantiate_checkpoint_dataclass(cls: type[Any], payload: Any) -> Any | None: - if not isinstance(cls, type): - logger.debug(f"Checkpoint decoder received non-type dataclass reference: {cls!r}") - return None - - if isinstance(payload, dict): - try: - return cls(**payload) # type: ignore[arg-type] - except TypeError as exc: - logger.debug(f"Checkpoint decoder could not call {cls.__name__}(**payload): {exc}") - except Exception as exc: - logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}(**payload): {exc}") - try: - instance = object.__new__(cls) - except Exception as exc: - logger.debug(f"Checkpoint decoder could not allocate {cls.__name__} without __init__: {exc}") - return None - for key, val in payload.items(): # type: ignore[attr-defined] - try: - setattr(instance, key, val) # type: ignore[arg-type] - except Exception as exc: - logger.debug(f"Checkpoint decoder could not set attribute {key} on {cls.__name__}: {exc}") - return instance - - try: - return cls(payload) # type: ignore[call-arg] - except TypeError as exc: - logger.debug(f"Checkpoint decoder could not call {cls.__name__}({payload!r}): {exc}") + pickled = base64.b64decode(encoded.encode("ascii")) + return pickle.loads(pickled) # nosec # noqa: S301 except Exception as exc: - logger.warning(f"Checkpoint decoder encountered unexpected error calling {cls.__name__}({payload!r}): {exc}") - return None + raise CheckpointDecodingError(f"Failed to decode pickled checkpoint data: {exc}") from exc + + +def _type_to_key(t: type) -> str: + """Convert a type to a module:qualname string.""" + return f"{t.__module__}:{t.__qualname__}" diff --git a/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py b/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py deleted file mode 100644 index fe00c1a287..0000000000 --- a/python/packages/core/agent_framework/_workflows/_checkpoint_summary.py +++ /dev/null @@ -1,49 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import logging -from dataclasses import dataclass - -from ._checkpoint import WorkflowCheckpoint -from ._const import EXECUTOR_STATE_KEY -from ._events import WorkflowEvent - -logger = logging.getLogger(__name__) - - -@dataclass -class WorkflowCheckpointSummary: - """Human-readable summary of a workflow checkpoint.""" - - checkpoint_id: str - timestamp: str - iteration_count: int - targets: list[str] - executor_ids: list[str] - status: str - pending_request_info_events: list[WorkflowEvent] - - -def get_checkpoint_summary(checkpoint: WorkflowCheckpoint) -> WorkflowCheckpointSummary: - targets = sorted(checkpoint.messages.keys()) - executor_ids = sorted(checkpoint.state.get(EXECUTOR_STATE_KEY, {}).keys()) - pending_request_info_events = [ - WorkflowEvent.from_dict(request) for request in checkpoint.pending_request_info_events.values() - ] - - status = "idle" - if pending_request_info_events: - status = "awaiting request response" - elif not checkpoint.messages and "finalise" in executor_ids: - status = "completed" - elif checkpoint.messages: - status = "awaiting next superstep" - - return WorkflowCheckpointSummary( - checkpoint_id=checkpoint.checkpoint_id, - timestamp=checkpoint.timestamp, - iteration_count=checkpoint.iteration_count, - targets=targets, - executor_ids=executor_ids, - status=status, - pending_request_info_events=pending_request_info_events, - ) diff --git a/python/packages/core/agent_framework/_workflows/_conversation_state.py b/python/packages/core/agent_framework/_workflows/_conversation_state.py deleted file mode 100644 index 95945998df..0000000000 --- a/python/packages/core/agent_framework/_workflows/_conversation_state.py +++ /dev/null @@ -1,75 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -from collections.abc import Iterable -from typing import Any, cast - -from agent_framework import Message - -from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value - -"""Utilities for serializing and deserializing chat conversations for persistence. - -These helpers convert rich `Message` instances to checkpoint-friendly payloads -using the same encoding primitives as the workflow runner. This preserves -`additional_properties` and other metadata without relying on unsafe mechanisms -such as pickling. -""" - - -def encode_chat_messages(messages: Iterable[Message]) -> list[dict[str, Any]]: - """Serialize chat messages into checkpoint-safe payloads.""" - encoded: list[dict[str, Any]] = [] - for message in messages: - encoded.append({ - "role": encode_checkpoint_value(message.role), - "contents": [encode_checkpoint_value(content) for content in message.contents], - "author_name": message.author_name, - "message_id": message.message_id, - "additional_properties": { - key: encode_checkpoint_value(value) for key, value in message.additional_properties.items() - }, - }) - return encoded - - -def decode_chat_messages(payload: Iterable[dict[str, Any]]) -> list[Message]: - """Restore chat messages from checkpoint-safe payloads.""" - restored: list[Message] = [] - for item in payload: - if not isinstance(item, dict): - continue - - role_value = decode_checkpoint_value(item.get("role")) - if isinstance(role_value, str): - role = role_value - elif isinstance(role_value, dict) and "value" in role_value: - # Handle legacy serialization format - role = role_value["value"] - else: - role = "assistant" - - contents_field = item.get("contents", []) - contents: list[Any] = [] - if isinstance(contents_field, list): - contents_iter: list[Any] = contents_field # type: ignore[assignment] - for entry in contents_iter: - decoded_entry: Any = decode_checkpoint_value(entry) - contents.append(decoded_entry) - - additional_field = item.get("additional_properties", {}) - additional: dict[str, Any] = {} - if isinstance(additional_field, dict): - additional_dict = cast(dict[str, Any], additional_field) - for key, value in additional_dict.items(): - additional[key] = decode_checkpoint_value(value) - - restored.append( - Message( # type: ignore[call-overload] - role=role, - contents=contents, - author_name=item.get("author_name"), - message_id=item.get("message_id"), - additional_properties=additional, - ) - ) - return restored diff --git a/python/packages/core/agent_framework/_workflows/_events.py b/python/packages/core/agent_framework/_workflows/_events.py index 18e974e3e7..c4694bf31b 100644 --- a/python/packages/core/agent_framework/_workflows/_events.py +++ b/python/packages/core/agent_framework/_workflows/_events.py @@ -12,7 +12,6 @@ from dataclasses import dataclass from enum import Enum from typing import Any, Generic, Literal, cast -from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value from ._typing_utils import deserialize_type, serialize_type if sys.version_info >= (3, 13): @@ -396,7 +395,7 @@ class WorkflowEvent(Generic[DataT]): raise ValueError(f"to_dict() only supported for 'request_info' events, got '{self.type}'") return { "type": self.type, - "data": encode_checkpoint_value(self.data), + "data": self.data, "request_id": self._request_id, "source_executor_id": self._source_executor_id, "request_type": serialize_type(self._request_type) if self._request_type else None, @@ -410,7 +409,7 @@ class WorkflowEvent(Generic[DataT]): if prop not in data: raise KeyError(f"Missing '{prop}' field in WorkflowEvent dictionary.") - request_data = decode_checkpoint_value(data["data"]) + request_data = data["data"] request_type = deserialize_type(data["request_type"]) if request_type is not type(request_data): diff --git a/python/packages/core/agent_framework/_workflows/_runner.py b/python/packages/core/agent_framework/_workflows/_runner.py index 83ce5d8085..88281597a2 100644 --- a/python/packages/core/agent_framework/_workflows/_runner.py +++ b/python/packages/core/agent_framework/_workflows/_runner.py @@ -7,12 +7,7 @@ from collections import defaultdict from collections.abc import AsyncGenerator, Sequence from typing import Any -from ._checkpoint import CheckpointStorage, WorkflowCheckpoint -from ._checkpoint_encoding import ( - DATACLASS_MARKER, - MODEL_MARKER, - decode_checkpoint_value, -) +from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint from ._const import EXECUTOR_STATE_KEY from ._edge import EdgeGroup from ._edge_runner import EdgeRunner, create_edge_runner @@ -41,8 +36,9 @@ class Runner: executors: dict[str, Executor], state: State, ctx: RunnerContext, + workflow_name: str, + graph_signature_hash: str, max_iterations: int = 100, - workflow_id: str | None = None, ) -> None: """Initialize the runner with edges, state, and context. @@ -51,24 +47,24 @@ class Runner: executors: Map of executor IDs to executor instances. state: The state for the workflow. ctx: The runner context for the workflow. + workflow_name: The name of the workflow, used for checkpoint labeling. + graph_signature_hash: A hash representing the workflow graph topology for checkpoint validation. max_iterations: The maximum number of iterations to run. - workflow_id: The workflow ID for checkpointing. """ + # Workflow instance related attributes self._executors = executors self._edge_runners = [create_edge_runner(group, executors) for group in edge_groups] self._edge_runner_map = self._parse_edge_runners(self._edge_runners) self._ctx = ctx + self._workflow_name = workflow_name + self._graph_signature_hash = graph_signature_hash + + # Runner state related attributes self._iteration = 0 self._max_iterations = max_iterations self._state = state - self._workflow_id = workflow_id self._running = False self._resumed_from_checkpoint = False # Track whether we resumed - self.graph_signature_hash: str | None = None - - # Set workflow ID in context if provided - if workflow_id: - self._ctx.set_workflow_id(workflow_id) @property def context(self) -> RunnerContext: @@ -85,6 +81,7 @@ class Runner: raise WorkflowRunnerException("Runner is already running.") self._running = True + previous_checkpoint_id: CheckpointID | None = None try: # Emit any events already produced prior to entering loop if await self._ctx.has_events(): @@ -92,13 +89,12 @@ class Runner: for event in await self._ctx.drain_events(): yield event - # Create first checkpoint if there are messages from initial execution - if await self._ctx.has_messages() and self._ctx.has_checkpointing(): - if not self._resumed_from_checkpoint: - logger.info("Creating checkpoint after initial execution") - await self._create_checkpoint_if_enabled("after_initial_execution") - else: - logger.info("Skipping 'after_initial_execution' checkpoint because we resumed from a checkpoint") + # Create the first checkpoint. Checkpoints are usually considered to be created at the end of an iteration, + # we can think of the first checkpoint as being created at the end of a "superstep 0" which captures the + # states after which the start executor has run. Note that we execute the start executor outside of the + # main iteration loop. + if await self._ctx.has_messages() and not self._resumed_from_checkpoint: + previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id) while self._iteration < self._max_iterations: logger.info(f"Starting superstep {self._iteration + 1}") @@ -145,7 +141,7 @@ class Runner: self._state.commit() # Create checkpoint after each superstep iteration - await self._create_checkpoint_if_enabled(f"superstep_{self._iteration}") + previous_checkpoint_id = await self._create_checkpoint_if_enabled(previous_checkpoint_id) yield WorkflowEvent.superstep_completed(iteration=self._iteration) @@ -169,19 +165,6 @@ class Runner: """Inner loop to deliver a single message through an edge runner.""" return await edge_runner.send_message(message, self._state, self._ctx) - def _normalize_message_payload(message: WorkflowMessage) -> None: - data = message.data - if not isinstance(data, dict): - return - if MODEL_MARKER not in data and DATACLASS_MARKER not in data: - return - try: - decoded = decode_checkpoint_value(data) - except Exception as exc: # pragma: no cover - defensive - logger.debug("Failed to decode checkpoint payload during delivery: %s", exc) - return - message.data = decoded - # Route all messages through normal workflow edges associated_edge_runners = self._edge_runner_map.get(source_executor_id, []) if not associated_edge_runners: @@ -190,7 +173,6 @@ class Runner: return for message in messages: - _normalize_message_payload(message) # Deliver a message through all edge runners associated with the source executor concurrently. tasks = [_deliver_message_inner(edge_runner, message) for edge_runner in associated_edge_runners] await asyncio.gather(*tasks) @@ -199,35 +181,33 @@ class Runner: tasks = [_deliver_messages(source_executor_id, messages) for source_executor_id, messages in messages.items()] await asyncio.gather(*tasks) - async def _create_checkpoint_if_enabled(self, checkpoint_type: str) -> str | None: + async def _create_checkpoint_if_enabled(self, previous_checkpoint_id: CheckpointID | None) -> CheckpointID | None: """Create a checkpoint if checkpointing is enabled and attach a label and metadata.""" if not self._ctx.has_checkpointing(): return None try: - # Snapshot executor states + # Save executor states into the shared state before creating the checkpoint, + # so that they are included in the checkpoint payload. await self._save_executor_states() - checkpoint_category = "initial" if checkpoint_type == "after_initial_execution" else "superstep" - metadata = { - "superstep": self._iteration, - "checkpoint_type": checkpoint_category, - } - if self.graph_signature_hash: - metadata["graph_signature"] = self.graph_signature_hash + checkpoint_id = await self._ctx.create_checkpoint( + self._workflow_name, + self._graph_signature_hash, self._state, + previous_checkpoint_id, self._iteration, - metadata=metadata, ) - logger.info(f"Created {checkpoint_type} checkpoint: {checkpoint_id}") + + logger.info(f"Created checkpoint: {checkpoint_id}") return checkpoint_id except Exception as e: - logger.warning(f"Failed to create {checkpoint_type} checkpoint: {e}") + logger.warning(f"Failed to create checkpoint: {e}") return None async def restore_from_checkpoint( self, - checkpoint_id: str, + checkpoint_id: CheckpointID, checkpoint_storage: CheckpointStorage | None = None, ) -> None: """Restore workflow state from a checkpoint. @@ -249,7 +229,7 @@ class Runner: if self._ctx.has_checkpointing(): checkpoint = await self._ctx.load_checkpoint(checkpoint_id) elif checkpoint_storage is not None: - checkpoint = await checkpoint_storage.load_checkpoint(checkpoint_id) + checkpoint = await checkpoint_storage.load(checkpoint_id) else: raise WorkflowCheckpointException( "Cannot load checkpoint: no checkpointing configured in context or external storage provided." @@ -260,22 +240,14 @@ class Runner: raise WorkflowCheckpointException(f"Checkpoint {checkpoint_id} not found") # Validate the loaded checkpoint against the workflow - graph_hash = getattr(self, "graph_signature_hash", None) - checkpoint_hash = (checkpoint.metadata or {}).get("graph_signature") - if graph_hash and checkpoint_hash and graph_hash != checkpoint_hash: + if self._graph_signature_hash != checkpoint.graph_signature_hash: raise WorkflowCheckpointException( "Workflow graph has changed since the checkpoint was created. " "Please rebuild the original workflow before resuming." ) - if graph_hash and not checkpoint_hash: - logger.warning( - "Checkpoint %s does not include graph signature metadata; skipping topology validation.", - checkpoint_id, - ) - self._workflow_id = checkpoint.workflow_id # Restore state - self._state.import_state(decode_checkpoint_value(checkpoint.state)) + self._state.import_state(checkpoint.state) # Restore executor states using the restored state await self._restore_executor_states() # Apply the checkpoint to the context @@ -291,64 +263,19 @@ class Runner: raise WorkflowCheckpointException(f"Failed to restore from checkpoint {checkpoint_id}") from e async def _save_executor_states(self) -> None: - """Populate executor state by calling checkpoint hooks on executors. - - Backward compatibility behavior: - - If an executor defines an async or sync method `snapshot_state(self) -> dict`, use it. - - Else if it has a plain attribute `state` that is a dict, use that. - - Updated behavior: - - Executors should implement `on_checkpoint_save(self) -> dict` to provide state. - - This method will try the backward compatibility behavior first; if that does not yield state, - it falls back to the updated behavior. - - Only JSON-serializable dicts should be provided by executors. - """ + """Populate executor state by calling checkpoint hooks on executors.""" for exec_id, executor in self._executors.items(): - state_dict: dict[str, Any] | None = None - # Try backward compatibility behavior first - # TODO(@taochen): Remove backward compatibility - snapshot = getattr(executor, "snapshot_state", None) - try: - if callable(snapshot): - maybe = snapshot() - if asyncio.iscoroutine(maybe): # type: ignore[arg-type] - maybe = await maybe # type: ignore[assignment] - if isinstance(maybe, dict): - state_dict = maybe # type: ignore[assignment] - else: - state_attr = getattr(executor, "state", None) - if isinstance(state_attr, dict): - state_dict = state_attr # type: ignore[assignment] - except Exception as ex: # pragma: no cover - logger.debug(f"Executor {exec_id} snapshot_state failed: {ex}") - - if state_dict is None: - # Try the updated behavior only if backward compatibility did not yield state - try: - state_dict = await executor.on_checkpoint_save() - except Exception as ex: # pragma: no cover - raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex - + # Try the updated behavior only if backward compatibility did not yield state try: + state_dict = await executor.on_checkpoint_save() await self._set_executor_state(exec_id, state_dict) + except WorkflowCheckpointException: + raise except Exception as ex: # pragma: no cover - logger.debug(f"Failed to persist state for executor {exec_id}: {ex}") + raise WorkflowCheckpointException(f"Executor {exec_id} on_checkpoint_save failed") from ex async def _restore_executor_states(self) -> None: - """Restore executor state by calling restore hooks on executors. - - Backward compatibility behavior: - - If an executor defines an async or sync method `restore_state(self, state: dict)`, use it. - - Else, skip restoration for that executor. - - Updated behavior: - - Executors should implement `on_checkpoint_restore(self, state: dict)` to restore state. - - This method will try the backward compatibility behavior first; if that does not restore state, - it falls back to the updated behavior. - """ + """Restore executor state by calling restore hooks on executors.""" has_executor_states = self._state.has(EXECUTOR_STATE_KEY) if not has_executor_states: return @@ -369,29 +296,11 @@ class Runner: if not executor: raise WorkflowCheckpointException(f"Executor {executor_id} not found during state restoration.") - # Try backward compatibility behavior first - # TODO(@taochen): Remove backward compatibility - restored = False - restore_method = getattr(executor, "restore_state", None) + # Try the updated behavior only if backward compatibility did not restore try: - if callable(restore_method): - maybe = restore_method(state) - if asyncio.iscoroutine(maybe): # type: ignore[arg-type] - await maybe # type: ignore[arg-type] - restored = True + await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType] except Exception as ex: # pragma: no cover - defensive - raise WorkflowCheckpointException(f"Executor {executor_id} restore_state failed") from ex - - if not restored: - # Try the updated behavior only if backward compatibility did not restore - try: - await executor.on_checkpoint_restore(state) # pyright: ignore[reportUnknownArgumentType] - restored = True - except Exception as ex: # pragma: no cover - defensive - raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex - - if not restored: - logger.debug(f"Executor {executor_id} does not support state restoration; skipping.") + raise WorkflowCheckpointException(f"Executor {executor_id} on_checkpoint_restore failed") from ex def _parse_edge_runners(self, edge_runners: list[EdgeRunner]) -> dict[str, list[EdgeRunner]]: """Parse the edge runners of the workflow into a mapping where each source executor ID maps to its edge runners. diff --git a/python/packages/core/agent_framework/_workflows/_runner_context.py b/python/packages/core/agent_framework/_workflows/_runner_context.py index db6558306a..d52e135e91 100644 --- a/python/packages/core/agent_framework/_workflows/_runner_context.py +++ b/python/packages/core/agent_framework/_workflows/_runner_context.py @@ -4,25 +4,17 @@ from __future__ import annotations import asyncio import logging -import sys -import uuid from copy import copy from dataclasses import dataclass from enum import Enum from typing import Any, Protocol, TypeVar, runtime_checkable -from ._checkpoint import CheckpointStorage, WorkflowCheckpoint -from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value +from ._checkpoint import CheckpointID, CheckpointStorage, WorkflowCheckpoint from ._const import INTERNAL_SOURCE_ID from ._events import WorkflowEvent from ._state import State from ._typing_utils import is_instance_of -if sys.version_info >= (3, 11): - from typing import TypedDict # type: ignore # pragma: no cover -else: - from typing_extensions import TypedDict # type: ignore # pragma: no cover - logger = logging.getLogger(__name__) T = TypeVar("T") @@ -69,13 +61,13 @@ class WorkflowMessage: def to_dict(self) -> dict[str, Any]: """Convert the WorkflowMessage to a dictionary for serialization.""" return { - "data": encode_checkpoint_value(self.data), + "data": self.data, "source_id": self.source_id, "target_id": self.target_id, "type": self.type.value, "trace_contexts": self.trace_contexts, "source_span_ids": self.source_span_ids, - "original_request_info_event": encode_checkpoint_value(self.original_request_info_event), + "original_request_info_event": self.original_request_info_event, } @staticmethod @@ -89,28 +81,16 @@ class WorkflowMessage: raise KeyError("Missing 'source_id' field in WorkflowMessage dictionary.") return WorkflowMessage( - data=decode_checkpoint_value(data["data"]), + data=data["data"], source_id=data["source_id"], target_id=data.get("target_id"), type=MessageType(data.get("type", "standard")), trace_contexts=data.get("trace_contexts"), source_span_ids=data.get("source_span_ids"), - original_request_info_event=decode_checkpoint_value(data.get("original_request_info_event")), + original_request_info_event=data.get("original_request_info_event"), ) -class _WorkflowState(TypedDict): - """TypedDict representing the serializable state of a workflow execution. - - This includes all state data needed for checkpointing and restoration. - """ - - messages: dict[str, list[dict[str, Any]]] - state: dict[str, Any] - iteration_count: int - pending_request_info_events: dict[str, dict[str, Any]] - - @runtime_checkable class RunnerContext(Protocol): """Protocol for the execution context used by the runner. @@ -192,11 +172,6 @@ class RunnerContext(Protocol): """Clear runtime checkpoint storage override.""" ... - # Checkpointing APIs (optional, enabled by storage) - def set_workflow_id(self, workflow_id: str) -> None: - """Set the workflow ID for the context.""" - ... - def reset_for_new_run(self) -> None: """Reset the context for a new workflow run.""" ... @@ -219,16 +194,23 @@ class RunnerContext(Protocol): async def create_checkpoint( self, + workflow_name: str, + graph_signature_hash: str, state: State, + previous_checkpoint_id: CheckpointID | None, iteration_count: int, metadata: dict[str, Any] | None = None, - ) -> str: + ) -> CheckpointID: """Create a checkpoint of the current workflow state. Args: + workflow_name: The name of the workflow for which the checkpoint is being created. + graph_signature_hash: Hash of the workflow graph topology to + validate checkpoint compatibility during restore. state: The state to include in the checkpoint. This is needed to capture the full state of the workflow. The state is not managed by the context itself. + previous_checkpoint_id: The ID of the previous checkpoint, if any, to form a checkpoint chain. iteration_count: The current iteration count of the workflow. metadata: Optional metadata to associate with the checkpoint. @@ -237,7 +219,7 @@ class RunnerContext(Protocol): """ ... - async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: + async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint | None: """Load a checkpoint without mutating the current context state. Args: @@ -301,7 +283,6 @@ class InProcRunnerContext: # Checkpointing configuration/state self._checkpoint_storage = checkpoint_storage self._runtime_checkpoint_storage: CheckpointStorage | None = None - self._workflow_id: str | None = None # Streaming flag - set by workflow's run(..., stream=True) vs run(..., stream=False) self._streaming: bool = False @@ -376,34 +357,36 @@ class InProcRunnerContext: async def create_checkpoint( self, + workflow_name: str, + graph_signature_hash: str, state: State, + previous_checkpoint_id: CheckpointID | None, iteration_count: int, metadata: dict[str, Any] | None = None, - ) -> str: + ) -> CheckpointID: storage = self._get_effective_checkpoint_storage() if not storage: raise ValueError("Checkpoint storage not configured") - self._workflow_id = self._workflow_id or str(uuid.uuid4()) - workflow_state = self._get_serialized_workflow_state(state, iteration_count) - checkpoint = WorkflowCheckpoint( - workflow_id=self._workflow_id, - messages=workflow_state["messages"], - state=workflow_state["state"], - pending_request_info_events=workflow_state["pending_request_info_events"], - iteration_count=workflow_state["iteration_count"], + workflow_name=workflow_name, + graph_signature_hash=graph_signature_hash, + previous_checkpoint_id=previous_checkpoint_id, + messages=dict(self._messages), + state=state.export_state(), + pending_request_info_events=dict(self._pending_request_info_events), + iteration_count=iteration_count, metadata=metadata or {}, ) - checkpoint_id = await storage.save_checkpoint(checkpoint) - logger.info(f"Created checkpoint {checkpoint_id} for workflow {self._workflow_id}") + checkpoint_id = await storage.save(checkpoint) + logger.debug(f"Created checkpoint {checkpoint_id}") return checkpoint_id - async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: + async def load_checkpoint(self, checkpoint_id: CheckpointID) -> WorkflowCheckpoint: storage = self._get_effective_checkpoint_storage() if not storage: raise ValueError("Checkpoint storage not configured") - return await storage.load_checkpoint(checkpoint_id) + return await storage.load(checkpoint_id) def reset_for_new_run(self) -> None: """Reset the context for a new workflow run. @@ -422,24 +405,16 @@ class InProcRunnerContext: self._messages.clear() messages_data = checkpoint.messages for source_id, message_list in messages_data.items(): - self._messages[source_id] = [WorkflowMessage.from_dict(msg) for msg in message_list] + self._messages[source_id] = list(message_list) # Restore pending request info events self._pending_request_info_events.clear() - pending_requests_data = checkpoint.pending_request_info_events - for request_id, request_data in pending_requests_data.items(): - request_info_event = WorkflowEvent.from_dict(request_data) + for request_id, request_info_event in checkpoint.pending_request_info_events.items(): self._pending_request_info_events[request_id] = request_info_event await self.add_event(request_info_event) - # Restore workflow ID - self._workflow_id = checkpoint.workflow_id - # endregion Checkpointing - def set_workflow_id(self, workflow_id: str) -> None: - self._workflow_id = workflow_id - def set_streaming(self, streaming: bool) -> None: """Set whether agents should stream incremental updates. @@ -456,30 +431,14 @@ class InProcRunnerContext: """ return self._streaming - def _get_serialized_workflow_state(self, state: State, iteration_count: int) -> _WorkflowState: - serialized_messages: dict[str, list[dict[str, Any]]] = {} - for source_id, message_list in self._messages.items(): - serialized_messages[source_id] = [msg.to_dict() for msg in message_list] - - serialized_pending_request_info_events: dict[str, dict[str, Any]] = { - request_id: request.to_dict() for request_id, request in self._pending_request_info_events.items() - } - - return { - "messages": serialized_messages, - "state": encode_checkpoint_value(state.export_state()), - "iteration_count": iteration_count, - "pending_request_info_events": serialized_pending_request_info_events, - } - async def add_request_info_event(self, event: WorkflowEvent[Any]) -> None: """Add a request_info event to the context and track it for correlation. Args: event: The WorkflowEvent with type='request_info' to be added. """ - if event.request_id is None: - raise ValueError("request_info event must have a request_id") + if event.type != "request_info": + raise ValueError("Event type must be 'request_info'") self._pending_request_info_events[event.request_id] = event await self.add_event(event) diff --git a/python/packages/core/agent_framework/_workflows/_workflow.py b/python/packages/core/agent_framework/_workflows/_workflow.py index 88a92dc703..cd7dbb4a68 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow.py +++ b/python/packages/core/agent_framework/_workflows/_workflow.py @@ -175,9 +175,9 @@ class Workflow(DictConvertible): executors: dict[str, Executor], start_executor: Executor, runner_context: RunnerContext, - max_iterations: int = DEFAULT_MAX_ITERATIONS, - name: str | None = None, + name: str, description: str | None = None, + max_iterations: int = DEFAULT_MAX_ITERATIONS, output_executors: list[str] | None = None, **kwargs: Any, ): @@ -189,8 +189,12 @@ class Workflow(DictConvertible): start_executor: The starting executor for the workflow. runner_context: The RunnerContext instance to be used during workflow execution. max_iterations: The maximum number of iterations the workflow will run for convergence. - name: Optional human-readable name for the workflow. - description: Optional description of what the workflow does. + name: A human-readable name for the workflow. This can be used to identify the workflow in + checkpoints, and telemetry. If the workflow is built using WorkflowBuilder, this will be the + name of the builder. This name should be unique across different workflow definitions for + better observability and management. + description: Optional description of what the workflow does. If the workflow is built using + WorkflowBuilder, this will be the description of the builder. output_executors: Optional list of executor IDs whose outputs will be considered workflow outputs. If None or empty, all executor outputs are treated as workflow outputs. kwargs: Additional keyword arguments. Unused in this implementation. @@ -199,9 +203,15 @@ class Workflow(DictConvertible): self.executors = dict(executors) self.start_executor_id = start_executor.id self.max_iterations = max_iterations - self.id = str(uuid.uuid4()) self.name = name self.description = description + # Generate a unique ID for the workflow instance for monitoring purposes. This is not intended to be a + # stable identifier across instances created from the same builder, for that, use the name field. + self.id = str(uuid.uuid4()) + # Capture a canonical fingerprint of the workflow graph so checkpoints can assert they are resumed with + # an equivalent topology. + self.graph_signature = self._compute_graph_signature() + self.graph_signature_hash = self._hash_graph_signature(self.graph_signature) # Output events (WorkflowEvent with type='output') from these executors are treated as workflow outputs. # If None or empty, all executor outputs are considered workflow outputs. @@ -215,19 +225,14 @@ class Workflow(DictConvertible): self.executors, self._state, runner_context, + self.name, + self.graph_signature_hash, max_iterations=max_iterations, - workflow_id=self.id, ) # Flag to prevent concurrent workflow executions self._is_running = False - # Capture a canonical fingerprint of the workflow graph so checkpoints - # can assert they are resumed with an equivalent topology. - self._graph_signature = self._compute_graph_signature() - self._graph_signature_hash = self._hash_graph_signature(self._graph_signature) - self._runner.graph_signature_hash = self._graph_signature_hash - def _ensure_not_running(self) -> None: """Ensure the workflow is not already running.""" if self._is_running: @@ -241,6 +246,7 @@ class Workflow(DictConvertible): def to_dict(self) -> dict[str, Any]: """Serialize the workflow definition into a JSON-ready dictionary.""" data: dict[str, Any] = { + "name": self.name, "id": self.id, "start_executor_id": self.start_executor_id, "max_iterations": self.max_iterations, @@ -249,9 +255,6 @@ class Workflow(DictConvertible): "output_executors": self._output_executors, } - # Add optional name and description if provided - if self.name is not None: - data["name"] = self.name if self.description is not None: data["description"] = self.description @@ -565,6 +568,15 @@ class Workflow(DictConvertible): ): if event.type == "output" and not self._should_yield_output_event(event): continue + if event.type == "request_info" and event.request_id in (responses or {}): + # Don't yield request_info events for which we have responses to send - + # these are considered "handled". This prevents the caller from seeing + # events for requests they are already responding to. + # This usually happens when responses are provided with a checkpoint + # (restore then send), because the request_info events are stored in the + # checkpoint and would be emitted on restoration by the runner regardless + # of if a response is provided or not. + continue yield event async def _run_cleanup(self, checkpoint_storage: CheckpointStorage | None) -> None: @@ -753,7 +765,7 @@ class Workflow(DictConvertible): if isinstance(executor, WorkflowExecutor): executor_sig = { "type": executor_sig, - "sub_workflow": executor.workflow._graph_signature, + "sub_workflow": executor.workflow.graph_signature, } executors_signature[executor_id] = executor_sig @@ -796,7 +808,6 @@ class Workflow(DictConvertible): "start_executor": self.start_executor_id, "executors": executors_signature, "edge_groups": edge_groups_signature, - "max_iterations": self.max_iterations, } @staticmethod @@ -804,10 +815,6 @@ class Workflow(DictConvertible): canonical = json.dumps(signature, sort_keys=True, separators=(",", ":")) return hashlib.sha256(canonical.encode("utf-8")).hexdigest() - @property - def graph_signature_hash(self) -> str: - return self._graph_signature_hash - @property def input_types(self) -> list[type[Any] | types.UnionType]: """Get the input types of the workflow. diff --git a/python/packages/core/agent_framework/_workflows/_workflow_builder.py b/python/packages/core/agent_framework/_workflows/_workflow_builder.py index d7bdf9a918..1a71b3a49b 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_builder.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_builder.py @@ -2,6 +2,7 @@ import logging import sys +import uuid from collections.abc import Callable, Sequence from typing import Any @@ -88,7 +89,11 @@ class WorkflowBuilder: Args: max_iterations: Maximum number of iterations for workflow convergence. Default is 100. - name: Optional human-readable name for the workflow. + name: A human-readable name for the workflow builder. This name will be the identifier + for all workflow instances created from this builder. If not provided, a unique name + will be generated. This will be useful for versioning, monitoring, checkpointing, and + debugging workflows. Keeping this name unique across versions of your workflow definitions + is recommended for better observability and management. description: Optional description of what the workflow does. start_executor: The starting executor for the workflow. Can be an Executor instance or SupportsAgentRun instance. @@ -101,7 +106,7 @@ class WorkflowBuilder: self._start_executor: Executor | None = None self._checkpoint_storage: CheckpointStorage | None = checkpoint_storage self._max_iterations: int = max_iterations - self._name: str | None = name + self._name: str = name or f"WorkflowBuilder-{uuid.uuid4()!s}" self._description: str | None = description # Maps underlying SupportsAgentRun object id -> wrapped Executor so we reuse the same wrapper # across start_executor / add_edge calls. This avoids multiple AgentExecutor instances @@ -658,19 +663,18 @@ class WorkflowBuilder: executors, start_executor, context, - self._max_iterations, - name=self._name, + self._name, description=self._description, + max_iterations=self._max_iterations, output_executors=output_executors, ) build_attributes: dict[str, Any] = { + OtelAttr.WORKFLOW_BUILDER_NAME: self._name, OtelAttr.WORKFLOW_ID: workflow.id, OtelAttr.WORKFLOW_DEFINITION: workflow.to_json(), } - if workflow.name: - build_attributes[OtelAttr.WORKFLOW_NAME] = workflow.name - if workflow.description: - build_attributes[OtelAttr.WORKFLOW_DESCRIPTION] = workflow.description + if self._description: + build_attributes[OtelAttr.WORKFLOW_BUILDER_DESCRIPTION] = self._description span.set_attributes(build_attributes) # Add workflow build completed event diff --git a/python/packages/core/agent_framework/_workflows/_workflow_executor.py b/python/packages/core/agent_framework/_workflows/_workflow_executor.py index 3e5fd449bc..0d2c86070c 100644 --- a/python/packages/core/agent_framework/_workflows/_workflow_executor.py +++ b/python/packages/core/agent_framework/_workflows/_workflow_executor.py @@ -11,7 +11,7 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: from ._workflow import Workflow -from ._checkpoint_encoding import decode_checkpoint_value, encode_checkpoint_value +from ._checkpoint_encoding import decode_checkpoint_value from ._const import WORKFLOW_RUN_KWARGS_KEY from ._events import ( WorkflowEvent, @@ -454,8 +454,7 @@ class WorkflowExecutor(Executor): """Get the current state of the WorkflowExecutor for checkpointing purposes.""" return { "execution_contexts": { - execution_id: encode_checkpoint_value(execution_context) - for execution_id, execution_context in self._execution_contexts.items() + execution_id: execution_context for execution_id, execution_context in self._execution_contexts.items() }, "request_to_execution": dict(self._request_to_execution), } @@ -654,21 +653,6 @@ class WorkflowExecutor(Executor): try: # Resume the sub-workflow with all collected responses result = await self.workflow.run(responses=responses_to_send) - # Remove handled requests from result. The result may contain the original - # RequestInfoEvents that were already handled. This is due to checkpointing - # and rehydration of the workflow that re-adds the RequestInfoEvents to the - # workflow's _runner_context thus the event queue. When the workflow is resumed, - # those events will be emitted at the very beginning of the superstep, prior to - # processing messages/responses, creating the illusion that the workflow is - # requesting the same information again. - for request_id in responses_to_send: - event_to_remove = next( - (event for event in result if event.type == "request_info" and event.request_id == request_id), - None, - ) - if event_to_remove: - result.remove(event_to_remove) - # Process the workflow result using shared logic await self._process_workflow_result(result, execution_context, ctx) finally: diff --git a/python/packages/core/agent_framework/azure/_assistants_client.py b/python/packages/core/agent_framework/azure/_assistants_client.py index 52d219529b..89399ed833 100644 --- a/python/packages/core/agent_framework/azure/_assistants_client.py +++ b/python/packages/core/agent_framework/azure/_assistants_client.py @@ -7,12 +7,13 @@ from collections.abc import Mapping from typing import TYPE_CHECKING, Any, ClassVar, Generic from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI -from pydantic import ValidationError +from .._settings import load_settings from ..exceptions import ServiceInitializationError from ..openai import OpenAIAssistantsClient from ..openai._assistants_client import OpenAIAssistantsOptions -from ._shared import AzureOpenAISettings +from ._entra_id_authentication import get_entra_auth_token +from ._shared import DEFAULT_AZURE_TOKEN_ENDPOINT, AzureOpenAISettings, _apply_azure_defaults if TYPE_CHECKING: from azure.core.credentials import TokenCredential @@ -137,23 +138,21 @@ class AzureOpenAIAssistantsClient( client: AzureOpenAIAssistantsClient[MyOptions] = AzureOpenAIAssistantsClient() response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - try: - azure_openai_settings = AzureOpenAISettings( - # pydantic settings will see if there is a value, if not, will try the env var or .env file - api_key=api_key, # type: ignore - base_url=base_url, # type: ignore - endpoint=endpoint, # type: ignore - chat_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - default_api_version=self.DEFAULT_AZURE_API_VERSION, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Azure OpenAI settings.", ex) from ex + azure_openai_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + api_key=api_key, + base_url=base_url, + endpoint=endpoint, + chat_deployment_name=deployment_name, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + token_endpoint=token_endpoint, + ) + _apply_azure_defaults(azure_openai_settings, default_api_version=self.DEFAULT_AZURE_API_VERSION) - if not azure_openai_settings.chat_deployment_name: + if not azure_openai_settings["chat_deployment_name"]: raise ServiceInitializationError( "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." @@ -162,40 +161,41 @@ class AzureOpenAIAssistantsClient( # Handle authentication: try API key first, then AD token, then Entra ID if ( not async_client - and not azure_openai_settings.api_key + and not azure_openai_settings["api_key"] and not ad_token and not ad_token_provider - and azure_openai_settings.token_endpoint + and azure_openai_settings["token_endpoint"] and credential ): - ad_token = azure_openai_settings.get_azure_auth_token(credential) + token_ep = azure_openai_settings["token_endpoint"] or DEFAULT_AZURE_TOKEN_ENDPOINT + ad_token = get_entra_auth_token(credential, token_ep) - if not async_client and not azure_openai_settings.api_key and not ad_token and not ad_token_provider: + if not async_client and not azure_openai_settings["api_key"] and not ad_token and not ad_token_provider: raise ServiceInitializationError("The Azure OpenAI API key, ad_token, or ad_token_provider is required.") # Create Azure client if not provided if not async_client: client_params: dict[str, Any] = { - "api_version": azure_openai_settings.api_version, + "api_version": azure_openai_settings["api_version"], "default_headers": default_headers, } - if azure_openai_settings.api_key: - client_params["api_key"] = azure_openai_settings.api_key.get_secret_value() + if azure_openai_settings["api_key"]: + client_params["api_key"] = azure_openai_settings["api_key"].get_secret_value() elif ad_token: client_params["azure_ad_token"] = ad_token elif ad_token_provider: client_params["azure_ad_token_provider"] = ad_token_provider - if azure_openai_settings.base_url: - client_params["base_url"] = str(azure_openai_settings.base_url) - elif azure_openai_settings.endpoint: - client_params["azure_endpoint"] = str(azure_openai_settings.endpoint) + if azure_openai_settings["base_url"]: + client_params["base_url"] = str(azure_openai_settings["base_url"]) + elif azure_openai_settings["endpoint"]: + client_params["azure_endpoint"] = str(azure_openai_settings["endpoint"]) async_client = AsyncAzureOpenAI(**client_params) super().__init__( - model_id=azure_openai_settings.chat_deployment_name, + model_id=azure_openai_settings["chat_deployment_name"], assistant_id=assistant_id, assistant_name=assistant_name, assistant_description=assistant_description, diff --git a/python/packages/core/agent_framework/azure/_chat_client.py b/python/packages/core/agent_framework/azure/_chat_client.py index 0fcf99823a..485df4a6ed 100644 --- a/python/packages/core/agent_framework/azure/_chat_client.py +++ b/python/packages/core/agent_framework/azure/_chat_client.py @@ -12,7 +12,7 @@ from azure.core.credentials import TokenCredential from openai.lib.azure import AsyncAzureADTokenProvider, AsyncAzureOpenAI from openai.types.chat.chat_completion import Choice from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from agent_framework import ( Annotation, @@ -28,9 +28,11 @@ from agent_framework.observability import ChatTelemetryLayer from agent_framework.openai import OpenAIChatOptions from agent_framework.openai._chat_client import RawOpenAIChatClient +from .._settings import load_settings from ._shared import ( AzureOpenAIConfigMixin, AzureOpenAISettings, + _apply_azure_defaults, ) if sys.version_info >= (3, 13): @@ -247,37 +249,35 @@ class AzureOpenAIChatClient( # type: ignore[misc] client: AzureOpenAIChatClient[MyOptions] = AzureOpenAIChatClient() response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - try: - # Filter out any None values from the arguments - azure_openai_settings = AzureOpenAISettings( - # pydantic settings will see if there is a value, if not, will try the env var or .env file - api_key=api_key, # type: ignore - base_url=base_url, # type: ignore - endpoint=endpoint, # type: ignore - chat_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - ) - except ValidationError as exc: - raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc + azure_openai_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + api_key=api_key, + base_url=base_url, + endpoint=endpoint, + chat_deployment_name=deployment_name, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + token_endpoint=token_endpoint, + ) + _apply_azure_defaults(azure_openai_settings) - if not azure_openai_settings.chat_deployment_name: + if not azure_openai_settings["chat_deployment_name"]: raise ServiceInitializationError( "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " "or 'AZURE_OPENAI_CHAT_DEPLOYMENT_NAME' environment variable." ) super().__init__( - deployment_name=azure_openai_settings.chat_deployment_name, - endpoint=azure_openai_settings.endpoint, - base_url=azure_openai_settings.base_url, - api_version=azure_openai_settings.api_version, # type: ignore - api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None, + deployment_name=azure_openai_settings["chat_deployment_name"], + endpoint=azure_openai_settings["endpoint"], + base_url=azure_openai_settings["base_url"], + api_version=azure_openai_settings["api_version"], # type: ignore + api_key=azure_openai_settings["api_key"].get_secret_value() if azure_openai_settings["api_key"] else None, ad_token=ad_token, ad_token_provider=ad_token_provider, - token_endpoint=azure_openai_settings.token_endpoint, + token_endpoint=azure_openai_settings["token_endpoint"], credential=credential, default_headers=default_headers, client=async_client, diff --git a/python/packages/core/agent_framework/azure/_responses_client.py b/python/packages/core/agent_framework/azure/_responses_client.py index 0049979a6a..65335482fe 100644 --- a/python/packages/core/agent_framework/azure/_responses_client.py +++ b/python/packages/core/agent_framework/azure/_responses_client.py @@ -5,15 +5,15 @@ from __future__ import annotations import sys from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Generic -from urllib.parse import urljoin +from urllib.parse import urljoin, urlparse from azure.ai.projects.aio import AIProjectClient from azure.core.credentials import TokenCredential from openai import AsyncOpenAI from openai.lib.azure import AsyncAzureADTokenProvider -from pydantic import ValidationError from .._middleware import ChatMiddlewareLayer +from .._settings import load_settings from .._telemetry import AGENT_FRAMEWORK_USER_AGENT from .._tools import FunctionInvocationConfiguration, FunctionInvocationLayer from ..exceptions import ServiceInitializationError @@ -22,6 +22,7 @@ from ..openai._responses_client import RawOpenAIResponsesClient from ._shared import ( AzureOpenAIConfigMixin, AzureOpenAISettings, + _apply_azure_defaults, ) if sys.version_info >= (3, 13): @@ -82,7 +83,8 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] env_file_encoding: str | None = None, instruction_role: str | None = None, middleware: Sequence[MiddlewareTypes] | None = None, - function_invocation_configuration: FunctionInvocationConfiguration | None = None, + function_invocation_configuration: FunctionInvocationConfiguration + | None = None, **kwargs: Any, ) -> None: """Initialize an Azure OpenAI Responses client. @@ -188,54 +190,58 @@ class AzureOpenAIResponsesClient( # type: ignore[misc] deployment_name = str(model_id) # Project client path: create OpenAI client from an Azure AI Foundry project - if async_client is None and (project_client is not None or project_endpoint is not None): + if async_client is None and ( + project_client is not None or project_endpoint is not None + ): async_client = self._create_client_from_project( project_client=project_client, project_endpoint=project_endpoint, credential=credential, ) - try: - azure_openai_settings = AzureOpenAISettings( - # pydantic settings will see if there is a value, if not, will try the env var or .env file - api_key=api_key, # type: ignore - base_url=base_url, # type: ignore - endpoint=endpoint, # type: ignore - responses_deployment_name=deployment_name, - api_version=api_version, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - token_endpoint=token_endpoint, - default_api_version="preview", + azure_openai_settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + api_key=api_key, + base_url=base_url, + endpoint=endpoint, + responses_deployment_name=deployment_name, + api_version=api_version, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + token_endpoint=token_endpoint, + ) + _apply_azure_defaults(azure_openai_settings, default_api_version="preview") + # TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly + # while this feature is in preview. + # But we should only do this if we're on azure. Private deployments may not need this. + if ( + not azure_openai_settings.get("base_url") + and azure_openai_settings.get("endpoint") + and (hostname := urlparse(str(azure_openai_settings["endpoint"])).hostname) + and hostname.endswith(".openai.azure.com") + ): + azure_openai_settings["base_url"] = urljoin( + str(azure_openai_settings["endpoint"]), "/openai/v1/" ) - # TODO(peterychang): This is a temporary hack to ensure that the base_url is set correctly - # while this feature is in preview. - # But we should only do this if we're on azure. Private deployments may not need this. - if ( - not azure_openai_settings.base_url - and azure_openai_settings.endpoint - and azure_openai_settings.endpoint.host - and azure_openai_settings.endpoint.host.endswith(".openai.azure.com") - ): - azure_openai_settings.base_url = urljoin(str(azure_openai_settings.endpoint), "/openai/v1/") # type: ignore - except ValidationError as exc: - raise ServiceInitializationError(f"Failed to validate settings: {exc}") from exc - if not azure_openai_settings.responses_deployment_name: + if not azure_openai_settings["responses_deployment_name"]: raise ServiceInitializationError( "Azure OpenAI deployment name is required. Set via 'deployment_name' parameter " "or 'AZURE_OPENAI_RESPONSES_DEPLOYMENT_NAME' environment variable." ) super().__init__( - deployment_name=azure_openai_settings.responses_deployment_name, - endpoint=azure_openai_settings.endpoint, - base_url=azure_openai_settings.base_url, - api_version=azure_openai_settings.api_version, # type: ignore - api_key=azure_openai_settings.api_key.get_secret_value() if azure_openai_settings.api_key else None, + deployment_name=azure_openai_settings["responses_deployment_name"], + endpoint=azure_openai_settings["endpoint"], + base_url=azure_openai_settings["base_url"], + api_version=azure_openai_settings["api_version"], # type: ignore + api_key=azure_openai_settings["api_key"].get_secret_value() + if azure_openai_settings["api_key"] + else None, ad_token=ad_token, ad_token_provider=ad_token_provider, - token_endpoint=azure_openai_settings.token_endpoint, + token_endpoint=azure_openai_settings["token_endpoint"], credential=credential, default_headers=default_headers, client=async_client, diff --git a/python/packages/core/agent_framework/azure/_shared.py b/python/packages/core/agent_framework/azure/_shared.py index 5ef0585f96..c3e4399555 100644 --- a/python/packages/core/agent_framework/azure/_shared.py +++ b/python/packages/core/agent_framework/azure/_shared.py @@ -11,28 +11,26 @@ from typing import Any, ClassVar, Final from azure.core.credentials import TokenCredential from openai import AsyncOpenAI from openai.lib.azure import AsyncAzureOpenAI -from pydantic import SecretStr, model_validator -from .._pydantic import AFBaseSettings, HTTPsUrl +from .._settings import SecretString from .._telemetry import APP_INFO, prepend_agent_framework_to_user_agent from ..exceptions import ServiceInitializationError from ..openai._shared import OpenAIBase from ._entra_id_authentication import get_entra_auth_token -if sys.version_info >= (3, 11): - from typing import Self # pragma: no cover -else: - from typing_extensions import Self # pragma: no cover - - logger: logging.Logger = logging.getLogger(__name__) +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + DEFAULT_AZURE_API_VERSION: Final[str] = "2024-10-21" DEFAULT_AZURE_TOKEN_ENDPOINT: Final[str] = "https://cognitiveservices.azure.com/.default" # noqa: S105 -class AzureOpenAISettings(AFBaseSettings): +class AzureOpenAISettings(TypedDict, total=False): """AzureOpenAI model settings. The settings are first loaded from environment variables with the prefix 'AZURE_OPENAI_'. @@ -62,7 +60,7 @@ class AzureOpenAISettings(AFBaseSettings): found in the Keys & Endpoint section when examining your resource in the Azure portal. You can use either KEY1 or KEY2. Can be set via environment variable AZURE_OPENAI_API_KEY. - api_version: The API version to use. The default value is `default_api_version`. + api_version: The API version to use. The default value is `DEFAULT_AZURE_API_VERSION`. Can be set via environment variable AZURE_OPENAI_API_VERSION. base_url: The url of the Azure deployment. This value can be found in the Keys & Endpoint section when examining @@ -71,14 +69,8 @@ class AzureOpenAISettings(AFBaseSettings): use endpoint if you only want to supply the endpoint. Can be set via environment variable AZURE_OPENAI_BASE_URL. token_endpoint: The token endpoint to use to retrieve the authentication token. - The default value is `default_token_endpoint`. + The default value is `DEFAULT_AZURE_TOKEN_ENDPOINT`. Can be set via environment variable AZURE_OPENAI_TOKEN_ENDPOINT. - default_api_version: The default API version to use if not specified. - The default value is "2024-10-21". - default_token_endpoint: The default token endpoint to use if not specified. - The default value is "https://cognitiveservices.azure.com/.default". - env_file_path: The path to the .env file to load settings from. - env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. Examples: .. code-block:: python @@ -89,60 +81,46 @@ class AzureOpenAISettings(AFBaseSettings): # Set AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com # Set AZURE_OPENAI_CHAT_DEPLOYMENT_NAME=gpt-4 # Set AZURE_OPENAI_API_KEY=your-key - settings = AzureOpenAISettings() + settings = load_settings(AzureOpenAISettings, env_prefix="AZURE_OPENAI_") # Or passing parameters directly - settings = AzureOpenAISettings( - endpoint="https://your-endpoint.openai.azure.com", chat_deployment_name="gpt-4", api_key="your-key" + settings = load_settings( + AzureOpenAISettings, + env_prefix="AZURE_OPENAI_", + endpoint="https://your-endpoint.openai.azure.com", + chat_deployment_name="gpt-4", + api_key="your-key", ) # Or loading from a .env file - settings = AzureOpenAISettings(env_file_path="path/to/.env") + settings = load_settings(AzureOpenAISettings, env_prefix="AZURE_OPENAI_", env_file_path="path/to/.env") """ - env_prefix: ClassVar[str] = "AZURE_OPENAI_" + chat_deployment_name: str | None + responses_deployment_name: str | None + endpoint: str | None + base_url: str | None + api_key: SecretString | None + api_version: str | None + token_endpoint: str | None - chat_deployment_name: str | None = None - responses_deployment_name: str | None = None - endpoint: HTTPsUrl | None = None - base_url: HTTPsUrl | None = None - api_key: SecretStr | None = None - api_version: str | None = None - token_endpoint: str | None = None - default_api_version: str = DEFAULT_AZURE_API_VERSION - default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT - def get_azure_auth_token( - self, credential: TokenCredential, token_endpoint: str | None = None, **kwargs: Any - ) -> str | None: - """Retrieve a Microsoft Entra Auth Token for a given token endpoint for the use with Azure OpenAI. +def _apply_azure_defaults( + settings: AzureOpenAISettings, + default_api_version: str = DEFAULT_AZURE_API_VERSION, + default_token_endpoint: str = DEFAULT_AZURE_TOKEN_ENDPOINT, +) -> None: + """Apply default values for api_version and token_endpoint after loading settings. - The required role for the token is `Cognitive Services OpenAI Contributor`. - The token endpoint may be specified as an environment variable, via the .env - file or as an argument. If the token endpoint is not provided, the default is None. - The `token_endpoint` argument takes precedence over the `token_endpoint` attribute. - - Args: - credential: The Azure AD credential to use. - token_endpoint: The token endpoint to use. Defaults to `https://cognitiveservices.azure.com/.default`. - - Keyword Args: - **kwargs: Additional keyword arguments to pass to the token retrieval method. - - Returns: - The Azure token or None if the token could not be retrieved. - - Raises: - ServiceInitializationError: If the token endpoint is not provided. - """ - endpoint_to_use = token_endpoint or self.token_endpoint or self.default_token_endpoint - return get_entra_auth_token(credential, endpoint_to_use, **kwargs) - - @model_validator(mode="after") - def _validate_fields(self) -> Self: - self.api_version = self.api_version or self.default_api_version - self.token_endpoint = self.token_endpoint or self.default_token_endpoint - return self + Args: + settings: The loaded Azure OpenAI settings dict. + default_api_version: The default API version to use if not set. + default_token_endpoint: The default token endpoint to use if not set. + """ + if not settings.get("api_version"): + settings["api_version"] = default_api_version + if not settings.get("token_endpoint"): + settings["token_endpoint"] = default_token_endpoint class AzureOpenAIConfigMixin(OpenAIBase): @@ -154,8 +132,8 @@ class AzureOpenAIConfigMixin(OpenAIBase): def __init__( self, deployment_name: str, - endpoint: HTTPsUrl | None = None, - base_url: HTTPsUrl | None = None, + endpoint: str | None = None, + base_url: str | None = None, api_version: str = DEFAULT_AZURE_API_VERSION, api_key: str | None = None, ad_token: str | None = None, @@ -170,7 +148,7 @@ class AzureOpenAIConfigMixin(OpenAIBase): """Internal class for configuring a connection to an Azure OpenAI service. The `validate_call` decorator is used with a configuration that allows arbitrary types. - This is necessary for types like `HTTPsUrl` and `OpenAIModelTypes`. + This is necessary for types like `str` and `OpenAIModelTypes`. Args: deployment_name: Name of the deployment. diff --git a/python/packages/core/agent_framework/exceptions.py b/python/packages/core/agent_framework/exceptions.py index 971b612ea3..45296ad74c 100644 --- a/python/packages/core/agent_framework/exceptions.py +++ b/python/packages/core/agent_framework/exceptions.py @@ -146,3 +146,9 @@ class ContentError(AgentFrameworkException): """An error occurred while processing content.""" pass + + +class SettingNotFoundError(AgentFrameworkException): + """A required setting could not be resolved from any source.""" + + pass diff --git a/python/packages/core/agent_framework/observability.py b/python/packages/core/agent_framework/observability.py index c97ae0168a..ac3c493309 100644 --- a/python/packages/core/agent_framework/observability.py +++ b/python/packages/core/agent_framework/observability.py @@ -18,11 +18,10 @@ from opentelemetry import metrics, trace from opentelemetry.sdk.resources import Resource from opentelemetry.semconv.attributes import service_attributes from opentelemetry.semconv_ai import Meters, SpanAttributes -from pydantic import PrivateAttr from . import __version__ as version_info from ._logging import get_logger -from ._pydantic import AFBaseSettings +from ._settings import load_settings if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -196,6 +195,8 @@ class OtelAttr(str, Enum): # Workflow attributes WORKFLOW_ID = "workflow.id" + WORKFLOW_BUILDER_NAME = "workflow_builder.name" + WORKFLOW_BUILDER_DESCRIPTION = "workflow_builder.description" WORKFLOW_NAME = "workflow.name" WORKFLOW_DESCRIPTION = "workflow.description" WORKFLOW_DEFINITION = "workflow.definition" @@ -564,7 +565,16 @@ def create_metric_views() -> list[View]: ] -class ObservabilitySettings(AFBaseSettings): +class _ObservabilitySettingsData(TypedDict, total=False): + """TypedDict schema for observability settings fields.""" + + enable_instrumentation: bool | None + enable_sensitive_data: bool | None + enable_console_exporters: bool | None + vs_code_extension_port: int | None + + +class ObservabilitySettings: """Settings for Agent Framework Observability. If the environment variables are not found, the settings can @@ -601,23 +611,27 @@ class ObservabilitySettings(AFBaseSettings): settings = ObservabilitySettings(enable_instrumentation=True, enable_console_exporters=True) """ - env_prefix: ClassVar[str] = "" - - enable_instrumentation: bool = False - enable_sensitive_data: bool = False - enable_console_exporters: bool = False - vs_code_extension_port: int | None = None - _resource: Resource = PrivateAttr() - _executed_setup: bool = PrivateAttr(default=False) - def __init__(self, **kwargs: Any) -> None: """Initialize the settings and create the resource.""" - super().__init__(**kwargs) - # Create resource with env file settings - self._resource = create_resource( - env_file_path=self.env_file_path, - env_file_encoding=self.env_file_encoding, + env_file_path = kwargs.pop("env_file_path", None) + env_file_encoding = kwargs.pop("env_file_encoding", None) + data = load_settings( + _ObservabilitySettingsData, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + **kwargs, ) + self.enable_instrumentation: bool = data.get("enable_instrumentation") or False + self.enable_sensitive_data: bool = data.get("enable_sensitive_data") or False + self.enable_console_exporters: bool = data.get("enable_console_exporters") or False + self.vs_code_extension_port: int | None = data.get("vs_code_extension_port") + self.env_file_path = env_file_path + self.env_file_encoding = env_file_encoding + self._resource = create_resource( + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) + self._executed_setup = False @property def ENABLED(self) -> bool: diff --git a/python/packages/core/agent_framework/openai/_assistant_provider.py b/python/packages/core/agent_framework/openai/_assistant_provider.py index 8095f04fe1..90820ec5d2 100644 --- a/python/packages/core/agent_framework/openai/_assistant_provider.py +++ b/python/packages/core/agent_framework/openai/_assistant_provider.py @@ -8,7 +8,9 @@ from typing import TYPE_CHECKING, Any, Generic, cast from openai import AsyncOpenAI from openai.types.beta.assistant import Assistant -from pydantic import BaseModel, SecretStr, ValidationError +from pydantic import BaseModel + +from agent_framework._settings import SecretString, load_settings from .._agents import Agent from .._memory import ContextProvider @@ -107,7 +109,7 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]): self, client: AsyncOpenAI | None = None, *, - api_key: str | SecretStr | Callable[[], str | Awaitable[str]] | None = None, + api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None = None, org_id: str | None = None, base_url: str | None = None, env_file_path: str | None = None, @@ -147,35 +149,34 @@ class OpenAIAssistantProvider(Generic[OptionsCoT]): if client is None: # Load settings and create client - try: - settings = OpenAISettings( - api_key=api_key, # type: ignore[reportArgumentType] - org_id=org_id, - base_url=base_url, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex + settings = load_settings( + OpenAISettings, + env_prefix="OPENAI_", + api_key=api_key, + org_id=org_id, + base_url=base_url, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) - if not settings.api_key: + if not settings["api_key"]: raise ServiceInitializationError( "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." ) # Get API key value api_key_value: str | Callable[[], str | Awaitable[str]] | None - if isinstance(settings.api_key, SecretStr): - api_key_value = settings.api_key.get_secret_value() + if isinstance(settings["api_key"], SecretString): + api_key_value = settings["api_key"].get_secret_value() else: - api_key_value = settings.api_key + api_key_value = settings["api_key"] # Create client client_args: dict[str, Any] = {"api_key": api_key_value} - if settings.org_id: - client_args["organization"] = settings.org_id - if settings.base_url: - client_args["base_url"] = settings.base_url + if settings["org_id"]: + client_args["organization"] = settings["org_id"] + if settings["base_url"]: + client_args["base_url"] = settings["base_url"] self._client = AsyncOpenAI(**client_args) diff --git a/python/packages/core/agent_framework/openai/_assistants_client.py b/python/packages/core/agent_framework/openai/_assistants_client.py index 2c243fbb04..218dacbea8 100644 --- a/python/packages/core/agent_framework/openai/_assistants_client.py +++ b/python/packages/core/agent_framework/openai/_assistants_client.py @@ -27,10 +27,11 @@ from openai.types.beta.threads import ( from openai.types.beta.threads.run_create_params import AdditionalMessage from openai.types.beta.threads.run_submit_tool_outputs_params import ToolOutput from openai.types.beta.threads.runs import RunStep -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from .._clients import BaseChatClient from .._middleware import ChatMiddlewareLayer +from .._settings import load_settings from .._tools import ( FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -343,35 +344,34 @@ class OpenAIAssistantsClient( # type: ignore[misc] client: OpenAIAssistantsClient[MyOptions] = OpenAIAssistantsClient(model_id="gpt-4") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - try: - openai_settings = OpenAISettings( - api_key=api_key, # type: ignore[reportArgumentType] - base_url=base_url, - org_id=org_id, - chat_model_id=model_id, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex + openai_settings = load_settings( + OpenAISettings, + env_prefix="OPENAI_", + api_key=api_key, + base_url=base_url, + org_id=org_id, + chat_model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) - if not async_client and not openai_settings.api_key: + if not async_client and not openai_settings["api_key"]: raise ServiceInitializationError( "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." ) - if not openai_settings.chat_model_id: + if not openai_settings["chat_model_id"]: raise ServiceInitializationError( "OpenAI model ID is required. " "Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable." ) super().__init__( - model_id=openai_settings.chat_model_id, - api_key=self._get_api_key(openai_settings.api_key), - org_id=openai_settings.org_id, + model_id=openai_settings["chat_model_id"], + api_key=self._get_api_key(openai_settings["api_key"]), + org_id=openai_settings["org_id"], default_headers=default_headers, client=async_client, - base_url=openai_settings.base_url, + base_url=openai_settings["base_url"], middleware=middleware, function_invocation_configuration=function_invocation_configuration, ) diff --git a/python/packages/core/agent_framework/openai/_chat_client.py b/python/packages/core/agent_framework/openai/_chat_client.py index f2335ff6be..b806848b75 100644 --- a/python/packages/core/agent_framework/openai/_chat_client.py +++ b/python/packages/core/agent_framework/openai/_chat_client.py @@ -17,11 +17,12 @@ from openai.types.chat.chat_completion_chunk import ChatCompletionChunk from openai.types.chat.chat_completion_chunk import Choice as ChunkChoice from openai.types.chat.chat_completion_message_custom_tool_call import ChatCompletionMessageCustomToolCall from openai.types.chat.completion_create_params import WebSearchOptions -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from .._clients import BaseChatClient from .._logging import get_logger from .._middleware import ChatAndFunctionMiddlewareTypes, ChatMiddlewareLayer +from .._settings import load_settings from .._tools import ( FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -718,33 +719,32 @@ class OpenAIChatClient( # type: ignore[misc] client: OpenAIChatClient[MyOptions] = OpenAIChatClient(model_id="") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - try: - openai_settings = OpenAISettings( - api_key=api_key, # type: ignore[reportArgumentType] - base_url=base_url, - org_id=org_id, - chat_model_id=model_id, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex + openai_settings = load_settings( + OpenAISettings, + env_prefix="OPENAI_", + api_key=api_key, + base_url=base_url, + org_id=org_id, + chat_model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) - if not async_client and not openai_settings.api_key: + if not async_client and not openai_settings["api_key"]: raise ServiceInitializationError( "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." ) - if not openai_settings.chat_model_id: + if not openai_settings["chat_model_id"]: raise ServiceInitializationError( "OpenAI model ID is required. " "Set via 'model_id' parameter or 'OPENAI_CHAT_MODEL_ID' environment variable." ) super().__init__( - model_id=openai_settings.chat_model_id, - api_key=self._get_api_key(openai_settings.api_key), - base_url=openai_settings.base_url if openai_settings.base_url else None, - org_id=openai_settings.org_id, + model_id=openai_settings["chat_model_id"], + api_key=self._get_api_key(openai_settings["api_key"]), + base_url=openai_settings["base_url"] if openai_settings["base_url"] else None, + org_id=openai_settings["org_id"], default_headers=default_headers, client=async_client, instruction_role=instruction_role, diff --git a/python/packages/core/agent_framework/openai/_responses_client.py b/python/packages/core/agent_framework/openai/_responses_client.py index f239221c49..9ec0751850 100644 --- a/python/packages/core/agent_framework/openai/_responses_client.py +++ b/python/packages/core/agent_framework/openai/_responses_client.py @@ -33,11 +33,12 @@ from openai.types.responses.tool_param import ( Mcp, ) from openai.types.responses.web_search_tool_param import WebSearchToolParam -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel from .._clients import BaseChatClient from .._logging import get_logger from .._middleware import ChatMiddlewareLayer +from .._settings import load_settings from .._tools import ( FunctionInvocationConfiguration, FunctionInvocationLayer, @@ -1810,36 +1811,35 @@ class OpenAIResponsesClient( # type: ignore[misc] client: OpenAIResponsesClient[MyOptions] = OpenAIResponsesClient(model_id="gpt-4o") response = await client.get_response("Hello", options={"my_custom_option": "value"}) """ - try: - openai_settings = OpenAISettings( - api_key=api_key, # type: ignore[reportArgumentType] - org_id=org_id, - base_url=base_url, - responses_model_id=model_id, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create OpenAI settings.", ex) from ex + openai_settings = load_settings( + OpenAISettings, + env_prefix="OPENAI_", + api_key=api_key, + org_id=org_id, + base_url=base_url, + responses_model_id=model_id, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) - if not async_client and not openai_settings.api_key: + if not async_client and not openai_settings["api_key"]: raise ServiceInitializationError( "OpenAI API key is required. Set via 'api_key' parameter or 'OPENAI_API_KEY' environment variable." ) - if not openai_settings.responses_model_id: + if not openai_settings["responses_model_id"]: raise ServiceInitializationError( "OpenAI model ID is required. " "Set via 'model_id' parameter or 'OPENAI_RESPONSES_MODEL_ID' environment variable." ) super().__init__( - model_id=openai_settings.responses_model_id, - api_key=self._get_api_key(openai_settings.api_key), - org_id=openai_settings.org_id, + model_id=openai_settings["responses_model_id"], + api_key=self._get_api_key(openai_settings["api_key"]), + org_id=openai_settings["org_id"], default_headers=default_headers, client=async_client, instruction_role=instruction_role, - base_url=openai_settings.base_url, + base_url=openai_settings["base_url"], middleware=middleware, function_invocation_configuration=function_invocation_configuration, **kwargs, diff --git a/python/packages/core/agent_framework/openai/_shared.py b/python/packages/core/agent_framework/openai/_shared.py index 008bd6ac12..c41f4b6247 100644 --- a/python/packages/core/agent_framework/openai/_shared.py +++ b/python/packages/core/agent_framework/openai/_shared.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +import sys from collections.abc import Awaitable, Callable, Mapping, MutableMapping, Sequence from copy import copy from typing import Any, ClassVar, Union @@ -20,11 +21,10 @@ from openai.types.images_response import ImagesResponse from openai.types.responses.response import Response from openai.types.responses.response_stream_event import ResponseStreamEvent from packaging.version import parse -from pydantic import SecretStr from .._logging import get_logger -from .._pydantic import AFBaseSettings from .._serialization import SerializationMixin +from .._settings import SecretString from .._telemetry import APP_INFO, USER_AGENT_KEY, prepend_agent_framework_to_user_agent from .._tools import FunctionTool from ..exceptions import ServiceInitializationError @@ -47,6 +47,11 @@ RESPONSE_TYPE = Union[ OPTION_TYPE = dict[str, Any] +if sys.version_info >= (3, 11): + from typing import TypedDict # type: ignore # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover + __all__ = ["OpenAISettings"] @@ -74,7 +79,7 @@ def _check_openai_version_for_callable_api_key() -> None: logger.warning(f"Could not check OpenAI version for callable API key support: {e}") -class OpenAISettings(AFBaseSettings): +class OpenAISettings(TypedDict, total=False): """OpenAI environment settings. The settings are first loaded from environment variables with the prefix 'OPENAI_'. @@ -93,8 +98,6 @@ class OpenAISettings(AFBaseSettings): Can be set via environment variable OPENAI_CHAT_MODEL_ID. responses_model_id: The OpenAI responses model ID to use, for example, gpt-4o or o1. Can be set via environment variable OPENAI_RESPONSES_MODEL_ID. - env_file_path: The path to the .env file to load settings from. - env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. Examples: .. code-block:: python @@ -104,22 +107,20 @@ class OpenAISettings(AFBaseSettings): # Using environment variables # Set OPENAI_API_KEY=sk-... # Set OPENAI_CHAT_MODEL_ID=gpt-4 - settings = OpenAISettings() + settings = load_settings(OpenAISettings, env_prefix="OPENAI_") # Or passing parameters directly - settings = OpenAISettings(api_key="sk-...", chat_model_id="gpt-4") + settings = load_settings(OpenAISettings, env_prefix="OPENAI_", api_key="sk-...", chat_model_id="gpt-4") # Or loading from a .env file - settings = OpenAISettings(env_file_path="path/to/.env") + settings = load_settings(OpenAISettings, env_prefix="OPENAI_", env_file_path="path/to/.env") """ - env_prefix: ClassVar[str] = "OPENAI_" - - api_key: SecretStr | Callable[[], str | Awaitable[str]] | None = None - base_url: str | None = None - org_id: str | None = None - chat_model_id: str | None = None - responses_model_id: str | None = None + api_key: SecretString | Callable[[], str | Awaitable[str]] | None + base_url: str | None + org_id: str | None + chat_model_id: str | None + responses_model_id: str | None class OpenAIBase(SerializationMixin): @@ -181,19 +182,18 @@ class OpenAIBase(SerializationMixin): return self.client def _get_api_key( - self, api_key: str | SecretStr | Callable[[], str | Awaitable[str]] | None + self, api_key: str | SecretString | Callable[[], str | Awaitable[str]] | None ) -> str | Callable[[], str | Awaitable[str]] | None: """Get the appropriate API key value for client initialization. Args: - api_key: The API key parameter which can be a string, SecretStr, callable, or None. + api_key: The API key parameter which can be a string, SecretString, callable, or None. Returns: For callable API keys: returns the callable directly. - For SecretStr API keys: returns the string value. - For string/None API keys: returns as-is. + For SecretString/string/None API keys: returns as-is (SecretString is a str subclass). """ - if isinstance(api_key, SecretStr): + if isinstance(api_key, SecretString): return api_key.get_secret_value() # Check version compatibility for callable API keys diff --git a/python/packages/core/pyproject.toml b/python/packages/core/pyproject.toml index 5a90b479e7..6bd92dbd48 100644 --- a/python/packages/core/pyproject.toml +++ b/python/packages/core/pyproject.toml @@ -26,7 +26,7 @@ dependencies = [ # utilities "typing-extensions", "pydantic>=2,<3", - "pydantic-settings>=2,<3", + "python-dotenv>=1,<2", # telemetry "opentelemetry-api>=1.39.0", "opentelemetry-sdk>=1.39.0", diff --git a/python/packages/core/tests/azure/test_azure_assistants_client.py b/python/packages/core/tests/azure/test_azure_assistants_client.py index bd940b13e5..bffa678a33 100644 --- a/python/packages/core/tests/azure/test_azure_assistants_client.py +++ b/python/packages/core/tests/azure/test_azure_assistants_client.py @@ -19,6 +19,7 @@ from agent_framework import ( SupportsChatGetResponse, tool, ) +from agent_framework._settings import SecretString from agent_framework.azure import AzureOpenAIAssistantsClient from agent_framework.exceptions import ServiceInitializationError @@ -556,19 +557,21 @@ def test_azure_assistants_client_entra_id_authentication() -> None: mock_credential = MagicMock() with ( - patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class, + patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings, + patch("agent_framework.azure._assistants_client.get_entra_auth_token") as mock_get_token, patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client, patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), ): - mock_settings = MagicMock() - mock_settings.chat_deployment_name = "test-deployment" - mock_settings.api_key = None # No API key to trigger Entra ID path - mock_settings.token_endpoint = "https://login.microsoftonline.com/test" - mock_settings.get_azure_auth_token.return_value = "entra-token-12345" - mock_settings.api_version = "2024-05-01-preview" - mock_settings.endpoint = "https://test-endpoint.openai.azure.com" - mock_settings.base_url = None - mock_settings_class.return_value = mock_settings + mock_load_settings.return_value = { + "chat_deployment_name": "test-deployment", + "responses_deployment_name": None, + "api_key": None, + "token_endpoint": "https://login.microsoftonline.com/test", + "api_version": "2024-05-01-preview", + "endpoint": "https://test-endpoint.openai.azure.com", + "base_url": None, + } + mock_get_token.return_value = "entra-token-12345" client = AzureOpenAIAssistantsClient( deployment_name="test-deployment", @@ -579,7 +582,7 @@ def test_azure_assistants_client_entra_id_authentication() -> None: ) # Verify Entra ID token was requested - mock_settings.get_azure_auth_token.assert_called_once_with(mock_credential) + mock_get_token.assert_called_once_with(mock_credential, "https://login.microsoftonline.com/test") # Verify client was created with the token mock_azure_client.assert_called_once() @@ -592,12 +595,16 @@ def test_azure_assistants_client_entra_id_authentication() -> None: def test_azure_assistants_client_no_authentication_error() -> None: """Test authentication validation error when no auth provided.""" - with patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class: - mock_settings = MagicMock() - mock_settings.chat_deployment_name = "test-deployment" - mock_settings.api_key = None # No API key - mock_settings.token_endpoint = None # No token endpoint - mock_settings_class.return_value = mock_settings + with patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings: + mock_load_settings.return_value = { + "chat_deployment_name": "test-deployment", + "responses_deployment_name": None, + "api_key": None, + "token_endpoint": None, + "api_version": "2024-05-01-preview", + "endpoint": "https://test-endpoint.openai.azure.com", + "base_url": None, + } # Test missing authentication raises error with pytest.raises(ServiceInitializationError, match="API key, ad_token, or ad_token_provider is required"): @@ -611,17 +618,19 @@ def test_azure_assistants_client_no_authentication_error() -> None: def test_azure_assistants_client_ad_token_authentication() -> None: """Test ad_token authentication client parameter path.""" with ( - patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class, + patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings, patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client, patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), ): - mock_settings = MagicMock() - mock_settings.chat_deployment_name = "test-deployment" - mock_settings.api_key = None # No API key - mock_settings.api_version = "2024-05-01-preview" - mock_settings.endpoint = "https://test-endpoint.openai.azure.com" - mock_settings.base_url = None - mock_settings_class.return_value = mock_settings + mock_load_settings.return_value = { + "chat_deployment_name": "test-deployment", + "responses_deployment_name": None, + "api_key": None, + "token_endpoint": None, + "api_version": "2024-05-01-preview", + "endpoint": "https://test-endpoint.openai.azure.com", + "base_url": None, + } client = AzureOpenAIAssistantsClient( deployment_name="test-deployment", @@ -645,17 +654,19 @@ def test_azure_assistants_client_ad_token_provider_authentication() -> None: mock_token_provider = MagicMock(spec=AsyncAzureADTokenProvider) with ( - patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class, + patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings, patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client, patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), ): - mock_settings = MagicMock() - mock_settings.chat_deployment_name = "test-deployment" - mock_settings.api_key = None # No API key - mock_settings.api_version = "2024-05-01-preview" - mock_settings.endpoint = "https://test-endpoint.openai.azure.com" - mock_settings.base_url = None - mock_settings_class.return_value = mock_settings + mock_load_settings.return_value = { + "chat_deployment_name": "test-deployment", + "responses_deployment_name": None, + "api_key": None, + "token_endpoint": None, + "api_version": "2024-05-01-preview", + "endpoint": "https://test-endpoint.openai.azure.com", + "base_url": None, + } client = AzureOpenAIAssistantsClient( deployment_name="test-deployment", @@ -675,17 +686,19 @@ def test_azure_assistants_client_ad_token_provider_authentication() -> None: def test_azure_assistants_client_base_url_configuration() -> None: """Test base_url client parameter path.""" with ( - patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class, + patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings, patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client, patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), ): - mock_settings = MagicMock() - mock_settings.chat_deployment_name = "test-deployment" - mock_settings.api_key.get_secret_value.return_value = "test-api-key" - mock_settings.base_url = "https://custom-base-url.com" - mock_settings.endpoint = None # No endpoint, should use base_url - mock_settings.api_version = "2024-05-01-preview" - mock_settings_class.return_value = mock_settings + mock_load_settings.return_value = { + "chat_deployment_name": "test-deployment", + "responses_deployment_name": None, + "api_key": SecretString("test-api-key"), + "token_endpoint": None, + "api_version": "2024-05-01-preview", + "endpoint": None, + "base_url": "https://custom-base-url.com", + } client = AzureOpenAIAssistantsClient( deployment_name="test-deployment", api_key="test-api-key", base_url="https://custom-base-url.com" @@ -704,17 +717,19 @@ def test_azure_assistants_client_base_url_configuration() -> None: def test_azure_assistants_client_azure_endpoint_configuration() -> None: """Test azure_endpoint client parameter path.""" with ( - patch("agent_framework.azure._assistants_client.AzureOpenAISettings") as mock_settings_class, + patch("agent_framework.azure._assistants_client.load_settings") as mock_load_settings, patch("agent_framework.azure._assistants_client.AsyncAzureOpenAI") as mock_azure_client, patch("agent_framework.openai.OpenAIAssistantsClient.__init__", return_value=None), ): - mock_settings = MagicMock() - mock_settings.chat_deployment_name = "test-deployment" - mock_settings.api_key.get_secret_value.return_value = "test-api-key" - mock_settings.base_url = None # No base_url - mock_settings.endpoint = "https://test-endpoint.openai.azure.com" - mock_settings.api_version = "2024-05-01-preview" - mock_settings_class.return_value = mock_settings + mock_load_settings.return_value = { + "chat_deployment_name": "test-deployment", + "responses_deployment_name": None, + "api_key": SecretString("test-api-key"), + "token_endpoint": None, + "api_version": "2024-05-01-preview", + "endpoint": "https://test-endpoint.openai.azure.com", + "base_url": None, + } client = AzureOpenAIAssistantsClient( deployment_name="test-deployment", diff --git a/python/packages/core/tests/azure/test_azure_chat_client.py b/python/packages/core/tests/azure/test_azure_chat_client.py index f0b34cc13d..7618755a76 100644 --- a/python/packages/core/tests/azure/test_azure_chat_client.py +++ b/python/packages/core/tests/azure/test_azure_chat_client.py @@ -109,8 +109,11 @@ def test_init_with_empty_endpoint_and_base_url(azure_openai_unit_test_env: dict[ @pytest.mark.parametrize("override_env_param_dict", [{"AZURE_OPENAI_ENDPOINT": "http://test.com"}], indirect=True) def test_init_with_invalid_endpoint(azure_openai_unit_test_env: dict[str, str]) -> None: - with pytest.raises(ServiceInitializationError): - AzureOpenAIChatClient() + # Note: URL scheme validation was previously handled by pydantic's HTTPsUrl type. + # After migrating to load_settings with TypedDict, endpoint is a plain string and no longer + # validated at the settings level. The Azure OpenAI SDK may reject invalid URLs at runtime. + client = AzureOpenAIChatClient() + assert client is not None @pytest.mark.parametrize("exclude_list", [["AZURE_OPENAI_BASE_URL"]], indirect=True) diff --git a/python/packages/core/tests/core/test_settings.py b/python/packages/core/tests/core/test_settings.py new file mode 100644 index 0000000000..12ff683924 --- /dev/null +++ b/python/packages/core/tests/core/test_settings.py @@ -0,0 +1,238 @@ +# Copyright (c) Microsoft. All rights reserved. + +"""Tests for load_settings() function.""" + +import os +import tempfile +from typing import TypedDict + +import pytest + +from agent_framework._settings import SecretString, load_settings + + +class SimpleSettings(TypedDict, total=False): + api_key: str | None + timeout: int | None + enabled: bool | None + rate_limit: float | None + + +class RequiredFieldSettings(TypedDict, total=False): + name: str | None + optional_field: str | None + + +class SecretSettings(TypedDict, total=False): + api_key: SecretString | None + username: str | None + + +class TestLoadSettingsBasic: + """Test basic load_settings functionality.""" + + def test_fields_are_none_when_unset(self) -> None: + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_") + + assert settings["api_key"] is None + assert settings["timeout"] is None + assert settings["enabled"] is None + assert settings["rate_limit"] is None + + def test_overrides(self) -> None: + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", timeout=60, enabled=False) + + assert settings["timeout"] == 60 + assert settings["enabled"] is False + + def test_none_overrides_are_filtered(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_APP_TIMEOUT", "120") + + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", timeout=None) + + # timeout=None is filtered, so env var wins + assert settings["timeout"] == 120 + + def test_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_APP_API_KEY", "test-key-123") + monkeypatch.setenv("TEST_APP_TIMEOUT", "120") + monkeypatch.setenv("TEST_APP_ENABLED", "false") + + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_") + + assert settings["api_key"] == "test-key-123" + assert settings["timeout"] == 120 + assert settings["enabled"] is False + + def test_overrides_beat_env_vars(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_APP_TIMEOUT", "120") + + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", timeout=60) + + assert settings["timeout"] == 60 + + def test_no_prefix(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("API_KEY", "no-prefix-key") + + settings = load_settings(SimpleSettings, api_key=None) + + assert settings["api_key"] == "no-prefix-key" + + +class TestDotenvFile: + """Test .env file loading.""" + + def test_load_from_dotenv(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TEST_APP_API_KEY", raising=False) + monkeypatch.delenv("TEST_APP_TIMEOUT", raising=False) + + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("TEST_APP_API_KEY=dotenv-key\n") + f.write("TEST_APP_TIMEOUT=90\n") + f.flush() + env_path = f.name + + try: + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=env_path) + + assert settings["api_key"] == "dotenv-key" + assert settings["timeout"] == 90 + finally: + os.unlink(env_path) + + def test_env_vars_override_dotenv(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_APP_API_KEY", "real-env-key") + + with tempfile.NamedTemporaryFile(mode="w", suffix=".env", delete=False) as f: + f.write("TEST_APP_API_KEY=dotenv-key\n") + f.flush() + env_path = f.name + + try: + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path=env_path) + + assert settings["api_key"] == "real-env-key" + finally: + os.unlink(env_path) + + def test_missing_dotenv_file(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("TEST_APP_API_KEY", raising=False) + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_", env_file_path="/nonexistent/.env") + + assert settings["api_key"] is None + + +class TestSecretString: + """Test SecretString type handling.""" + + def test_secretstring_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SECRET_API_KEY", "secret-value") + + settings = load_settings(SecretSettings, env_prefix="SECRET_") + + assert isinstance(settings["api_key"], SecretString) + assert settings["api_key"] == "secret-value" + + def test_secretstring_from_override(self) -> None: + settings = load_settings(SecretSettings, env_prefix="SECRET_", api_key="kwarg-secret") + + assert isinstance(settings["api_key"], SecretString) + assert settings["api_key"] == "kwarg-secret" + + def test_secretstring_masked_in_repr(self) -> None: + s = SecretString("my-secret") + assert "my-secret" not in repr(s) + assert "**********" in repr(s) + + def test_get_secret_value_compat(self) -> None: + s = SecretString("my-secret") + + assert s.get_secret_value() == "my-secret" + assert isinstance(s.get_secret_value(), str) + + +class TestTypeCoercion: + """Test type coercion from string values.""" + + def test_int_coercion(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_APP_TIMEOUT", "42") + + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_") + + assert settings["timeout"] == 42 + assert isinstance(settings["timeout"], int) + + def test_float_coercion(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_APP_RATE_LIMIT", "2.5") + + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_") + + assert settings["rate_limit"] == 2.5 + assert isinstance(settings["rate_limit"], float) + + def test_bool_coercion_true_values(self, monkeypatch: pytest.MonkeyPatch) -> None: + for true_val in ["true", "True", "TRUE", "1", "yes", "on"]: + monkeypatch.setenv("TEST_APP_ENABLED", true_val) + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_") + assert settings["enabled"] is True, f"Failed for {true_val}" + + def test_bool_coercion_false_values(self, monkeypatch: pytest.MonkeyPatch) -> None: + for false_val in ["false", "False", "FALSE", "0", "no", "off"]: + monkeypatch.setenv("TEST_APP_ENABLED", false_val) + settings = load_settings(SimpleSettings, env_prefix="TEST_APP_") + assert settings["enabled"] is False, f"Failed for {false_val}" + + +class TestRequiredFields: + """Test required field validation.""" + + def test_required_field_provided(self) -> None: + settings = load_settings( + RequiredFieldSettings, + env_prefix="TEST_", + required_fields=["name"], + name="my-app", + ) + + assert settings["name"] == "my-app" + assert settings["optional_field"] is None + + def test_required_field_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("TEST_NAME", "env-app") + + settings = load_settings(RequiredFieldSettings, env_prefix="TEST_", required_fields=["name"]) + + assert settings["name"] == "env-app" + + def test_required_field_missing_raises(self) -> None: + from agent_framework.exceptions import SettingNotFoundError + + with pytest.raises(SettingNotFoundError, match="Required setting 'name'"): + load_settings(RequiredFieldSettings, env_prefix="TEST_", required_fields=["name"]) + + def test_without_required_fields_param_allows_none(self) -> None: + settings = load_settings(RequiredFieldSettings, env_prefix="TEST_") + + assert settings["name"] is None + + +class TestOverrideTypeValidation: + """Test override type validation.""" + + def test_invalid_type_raises(self) -> None: + from agent_framework.exceptions import ServiceInitializationError + + with pytest.raises(ServiceInitializationError, match="Invalid type for setting 'api_key'"): + load_settings(SimpleSettings, env_prefix="TEST_", api_key={"bad": "type"}) + + def test_valid_types_accepted(self) -> None: + settings = load_settings(SimpleSettings, env_prefix="TEST_", timeout=42, enabled=True) + + assert settings["timeout"] == 42 + assert settings["enabled"] is True + + def test_str_accepted_for_secretstring(self) -> None: + settings = load_settings(SecretSettings, env_prefix="TEST_", api_key="plain-string") + + assert isinstance(settings["api_key"], SecretString) + assert settings["api_key"] == "plain-string" diff --git a/python/packages/core/tests/openai/test_assistant_provider.py b/python/packages/core/tests/openai/test_assistant_provider.py index 8a2b561d77..a9dbb039b6 100644 --- a/python/packages/core/tests/openai/test_assistant_provider.py +++ b/python/packages/core/tests/openai/test_assistant_provider.py @@ -131,9 +131,15 @@ class TestOpenAIAssistantProviderInit: """Test initialization fails without API key when settings return None.""" from unittest.mock import patch - # Mock OpenAISettings to return None for api_key - with patch("agent_framework.openai._assistant_provider.OpenAISettings") as mock_settings: - mock_settings.return_value.api_key = None + # Mock load_settings to return a dict with None for api_key + with patch("agent_framework.openai._assistant_provider.load_settings") as mock_load: + mock_load.return_value = { + "api_key": None, + "org_id": None, + "base_url": None, + "chat_model_id": None, + "responses_model_id": None, + } with pytest.raises(ServiceInitializationError) as exc_info: OpenAIAssistantProvider() diff --git a/python/packages/core/tests/openai/test_openai_assistants_client.py b/python/packages/core/tests/openai/test_openai_assistants_client.py index 2bf56a94aa..c50b026cb7 100644 --- a/python/packages/core/tests/openai/test_openai_assistants_client.py +++ b/python/packages/core/tests/openai/test_openai_assistants_client.py @@ -146,7 +146,7 @@ def test_init_auto_create_client( def test_init_validation_fail() -> None: """Test OpenAIAssistantsClient initialization with validation failure.""" with pytest.raises(ServiceInitializationError): - # Force failure by providing invalid model ID type - this should cause validation to fail + # Force failure by providing invalid model ID type OpenAIAssistantsClient(model_id=123, api_key="valid-key") # type: ignore diff --git a/python/packages/core/tests/workflow/test_agent_executor.py b/python/packages/core/tests/workflow/test_agent_executor.py index d3cef6f1fa..b4f431fd84 100644 --- a/python/packages/core/tests/workflow/test_agent_executor.py +++ b/python/packages/core/tests/workflow/test_agent_executor.py @@ -84,16 +84,17 @@ async def test_agent_executor_checkpoint_stores_and_restores_state() -> None: assert initial_agent.call_count == 1 # Verify checkpoint was created - checkpoints = await storage.list_checkpoints() - assert len(checkpoints) > 0 - - # Find a suitable checkpoint to restore (prefer superstep checkpoint) - checkpoints.sort(key=lambda cp: cp.timestamp) - restore_checkpoint = next( - (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"), - checkpoints[-1], + checkpoints = await storage.list_checkpoints(workflow_name=wf.name) + assert len(checkpoints) >= 2, ( + "Expected at least 2 checkpoints. The first one is after the start executor, " + "and the second one is after the agent execution." ) + # Get the second checkpoint which should contain the state after processing + # the first message by the start executor in the sequential workflow + checkpoints.sort(key=lambda cp: cp.timestamp) + restore_checkpoint = checkpoints[1] + # Verify checkpoint contains executor state with both cache and thread assert "_executor_state" in restore_checkpoint.state executor_states = restore_checkpoint.state["_executor_state"] diff --git a/python/packages/core/tests/workflow/test_checkpoint.py b/python/packages/core/tests/workflow/test_checkpoint.py index 9f6d57b2e1..b05d625502 100644 --- a/python/packages/core/tests/workflow/test_checkpoint.py +++ b/python/packages/core/tests/workflow/test_checkpoint.py @@ -2,21 +2,66 @@ import json import tempfile +from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path +import pytest + from agent_framework import ( FileCheckpointStorage, InMemoryCheckpointStorage, WorkflowCheckpoint, + WorkflowCheckpointException, + WorkflowEvent, ) +from agent_framework._workflows._runner_context import WorkflowMessage + + +# Module-level dataclasses for pickle serialization in roundtrip tests +@dataclass +class _TestToolApprovalRequest: + """Request data for tool approval in tests.""" + + tool_name: str + arguments: dict + timestamp: datetime + + +@dataclass +class _TestExecutorState: + """Executor state for tests.""" + + counter: int + history: list[str] + + +@dataclass +class _TestApprovalRequest: + """Approval request data for tests.""" + + action: str + params: tuple + + +@dataclass +class _TestCustomData: + """Custom data for tests.""" + + name: str + value: int + tags: list[str] + + +# region test WorkflowCheckpoint def test_workflow_checkpoint_default_values(): - checkpoint = WorkflowCheckpoint() + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") assert checkpoint.checkpoint_id != "" - assert checkpoint.workflow_id == "" + assert checkpoint.workflow_name == "test-workflow" + assert checkpoint.graph_signature_hash == "test-hash" assert checkpoint.timestamp != "" assert checkpoint.messages == {} assert checkpoint.state == {} @@ -30,7 +75,8 @@ def test_workflow_checkpoint_custom_values(): custom_timestamp = datetime.now(timezone.utc).isoformat() checkpoint = WorkflowCheckpoint( checkpoint_id="test-checkpoint-123", - workflow_id="test-workflow-456", + workflow_name="test-workflow-456", + graph_signature_hash="test-hash-456", timestamp=custom_timestamp, messages={"executor1": [{"data": "test"}]}, pending_request_info_events={"req123": {"data": "test"}}, @@ -41,7 +87,8 @@ def test_workflow_checkpoint_custom_values(): ) assert checkpoint.checkpoint_id == "test-checkpoint-123" - assert checkpoint.workflow_id == "test-workflow-456" + assert checkpoint.workflow_name == "test-workflow-456" + assert checkpoint.graph_signature_hash == "test-hash-456" assert checkpoint.timestamp == custom_timestamp assert checkpoint.messages == {"executor1": [{"data": "test"}]} assert checkpoint.state == {"key": "value"} @@ -51,23 +98,83 @@ def test_workflow_checkpoint_custom_values(): assert checkpoint.version == "2.0" +def test_workflow_checkpoint_to_dict(): + checkpoint = WorkflowCheckpoint( + checkpoint_id="test-id", + workflow_name="test-workflow", + graph_signature_hash="test-hash", + messages={"executor1": [{"data": "test"}]}, + state={"key": "value"}, + iteration_count=5, + ) + + result = checkpoint.to_dict() + + assert result["checkpoint_id"] == "test-id" + assert result["workflow_name"] == "test-workflow" + assert result["graph_signature_hash"] == "test-hash" + assert result["messages"] == {"executor1": [{"data": "test"}]} + assert result["state"] == {"key": "value"} + assert result["iteration_count"] == 5 + + +def test_workflow_checkpoint_previous_checkpoint_id(): + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + previous_checkpoint_id="previous-id-123", + ) + + assert checkpoint.previous_checkpoint_id == "previous-id-123" + + +# endregion + +# region InMemoryCheckpointStorage + + +def test_checkpoint_storage_protocol_compliance(): + # This test ensures both implementations have all required methods + memory_storage = InMemoryCheckpointStorage() + + with tempfile.TemporaryDirectory() as temp_dir: + file_storage = FileCheckpointStorage(temp_dir) + + for storage in [memory_storage, file_storage]: + # Test that all protocol methods exist and are callable + assert hasattr(storage, "save") + assert callable(storage.save) + assert hasattr(storage, "load") + assert callable(storage.load) + assert hasattr(storage, "list_checkpoints") + assert callable(storage.list_checkpoints) + assert hasattr(storage, "delete") + assert callable(storage.delete) + assert hasattr(storage, "list_checkpoint_ids") + assert callable(storage.list_checkpoint_ids) + assert hasattr(storage, "get_latest") + assert callable(storage.get_latest) + + async def test_memory_checkpoint_storage_save_and_load(): storage = InMemoryCheckpointStorage() checkpoint = WorkflowCheckpoint( - workflow_id="test-workflow", + workflow_name="test-workflow", + graph_signature_hash="test-hash", messages={"executor1": [{"data": "hello"}]}, pending_request_info_events={"req123": {"data": "test"}}, ) # Save checkpoint - saved_id = await storage.save_checkpoint(checkpoint) + saved_id = await storage.save(checkpoint) assert saved_id == checkpoint.checkpoint_id # Load checkpoint - loaded_checkpoint = await storage.load_checkpoint(checkpoint.checkpoint_id) + loaded_checkpoint = await storage.load(checkpoint.checkpoint_id) assert loaded_checkpoint is not None assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id - assert loaded_checkpoint.workflow_id == checkpoint.workflow_id + assert loaded_checkpoint.workflow_name == checkpoint.workflow_name + assert loaded_checkpoint.graph_signature_hash == checkpoint.graph_signature_hash assert loaded_checkpoint.messages == checkpoint.messages assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events @@ -75,96 +182,607 @@ async def test_memory_checkpoint_storage_save_and_load(): async def test_memory_checkpoint_storage_load_nonexistent(): storage = InMemoryCheckpointStorage() - result = await storage.load_checkpoint("nonexistent-id") - assert result is None + with pytest.raises(WorkflowCheckpointException): + await storage.load("nonexistent-id") -async def test_memory_checkpoint_storage_list_checkpoints(): +async def test_memory_checkpoint_storage_list(): storage = InMemoryCheckpointStorage() # Create checkpoints for different workflows - checkpoint1 = WorkflowCheckpoint(workflow_id="workflow-1") - checkpoint2 = WorkflowCheckpoint(workflow_id="workflow-1") - checkpoint3 = WorkflowCheckpoint(workflow_id="workflow-2") + checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1") + checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2") + checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3") - await storage.save_checkpoint(checkpoint1) - await storage.save_checkpoint(checkpoint2) - await storage.save_checkpoint(checkpoint3) + await storage.save(checkpoint1) + await storage.save(checkpoint2) + await storage.save(checkpoint3) - # Test list_checkpoint_ids for workflow-1 - workflow1_checkpoint_ids = await storage.list_checkpoint_ids("workflow-1") + # Test list_ids for workflow-1 + workflow1_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-1") assert len(workflow1_checkpoint_ids) == 2 assert checkpoint1.checkpoint_id in workflow1_checkpoint_ids assert checkpoint2.checkpoint_id in workflow1_checkpoint_ids - # Test list_checkpoints for workflow-1 (returns objects) - workflow1_checkpoints = await storage.list_checkpoints("workflow-1") + # Test list for workflow-1 (returns objects) + workflow1_checkpoints = await storage.list_checkpoints(workflow_name="workflow-1") assert len(workflow1_checkpoints) == 2 assert all(isinstance(cp, WorkflowCheckpoint) for cp in workflow1_checkpoints) assert {cp.checkpoint_id for cp in workflow1_checkpoints} == {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id} - # Test list_checkpoint_ids for workflow-2 - workflow2_checkpoint_ids = await storage.list_checkpoint_ids("workflow-2") + # Test list_ids for workflow-2 + workflow2_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-2") assert len(workflow2_checkpoint_ids) == 1 assert checkpoint3.checkpoint_id in workflow2_checkpoint_ids - # Test list_checkpoints for workflow-2 (returns objects) - workflow2_checkpoints = await storage.list_checkpoints("workflow-2") + # Test list for workflow-2 (returns objects) + workflow2_checkpoints = await storage.list_checkpoints(workflow_name="workflow-2") assert len(workflow2_checkpoints) == 1 assert workflow2_checkpoints[0].checkpoint_id == checkpoint3.checkpoint_id - # Test list_checkpoint_ids for non-existent workflow - empty_checkpoint_ids = await storage.list_checkpoint_ids("nonexistent-workflow") + # Test list_ids for non-existent workflow + empty_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="nonexistent-workflow") assert len(empty_checkpoint_ids) == 0 - # Test list_checkpoints for non-existent workflow - empty_checkpoints = await storage.list_checkpoints("nonexistent-workflow") + # Test list for non-existent workflow + empty_checkpoints = await storage.list_checkpoints(workflow_name="nonexistent-workflow") assert len(empty_checkpoints) == 0 - # Test list_checkpoint_ids without workflow filter (all checkpoints) - all_checkpoint_ids = await storage.list_checkpoint_ids() - assert len(all_checkpoint_ids) == 3 - expected_ids = {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id, checkpoint3.checkpoint_id} - assert expected_ids.issubset(set(all_checkpoint_ids)) - - # Test list_checkpoints without workflow filter (all checkpoints) - all_checkpoints = await storage.list_checkpoints() - assert len(all_checkpoints) == 3 - assert all(isinstance(cp, WorkflowCheckpoint) for cp in all_checkpoints) - async def test_memory_checkpoint_storage_delete(): storage = InMemoryCheckpointStorage() - checkpoint = WorkflowCheckpoint(workflow_id="test-workflow") + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") # Save checkpoint - await storage.save_checkpoint(checkpoint) - assert await storage.load_checkpoint(checkpoint.checkpoint_id) is not None + await storage.save(checkpoint) + assert await storage.load(checkpoint.checkpoint_id) is not None # Delete checkpoint - result = await storage.delete_checkpoint(checkpoint.checkpoint_id) + result = await storage.delete(checkpoint.checkpoint_id) assert result is True # Verify deletion - assert await storage.load_checkpoint(checkpoint.checkpoint_id) is None + with pytest.raises(WorkflowCheckpointException): + await storage.load(checkpoint.checkpoint_id) # Try to delete again - result = await storage.delete_checkpoint(checkpoint.checkpoint_id) + result = await storage.delete(checkpoint.checkpoint_id) assert result is False +async def test_memory_checkpoint_storage_get_latest(): + import asyncio + + storage = InMemoryCheckpointStorage() + + # Create checkpoints with small delays to ensure different timestamps + checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1") + await asyncio.sleep(0.01) + checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2") + await asyncio.sleep(0.01) + checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3") + + await storage.save(checkpoint1) + await storage.save(checkpoint2) + await storage.save(checkpoint3) + + # Test get_latest for workflow-1 + latest = await storage.get_latest(workflow_name="workflow-1") + assert latest is not None + assert latest.checkpoint_id == checkpoint2.checkpoint_id + + # Test get_latest for workflow-2 + latest2 = await storage.get_latest(workflow_name="workflow-2") + assert latest2 is not None + assert latest2.checkpoint_id == checkpoint3.checkpoint_id + + # Test get_latest for non-existent workflow + latest_none = await storage.get_latest(workflow_name="nonexistent-workflow") + assert latest_none is None + + +async def test_workflow_checkpoint_chaining_via_previous_checkpoint_id(): + """Test that consecutive checkpoints created by a workflow are properly chained via previous_checkpoint_id.""" + from typing_extensions import Never + + from agent_framework import WorkflowBuilder, WorkflowContext, handler + from agent_framework._workflows._executor import Executor + + class StartExecutor(Executor): + @handler + async def run(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message, target_id="middle") + + class MiddleExecutor(Executor): + @handler + async def process(self, message: str, ctx: WorkflowContext[str]) -> None: + await ctx.send_message(message + "-processed", target_id="finish") + + class FinishExecutor(Executor): + @handler + async def finish(self, message: str, ctx: WorkflowContext[Never, str]) -> None: + await ctx.yield_output(message + "-done") + + storage = InMemoryCheckpointStorage() + + start = StartExecutor(id="start") + middle = MiddleExecutor(id="middle") + finish = FinishExecutor(id="finish") + + workflow = ( + WorkflowBuilder(max_iterations=10, start_executor=start, checkpoint_storage=storage) + .add_edge(start, middle) + .add_edge(middle, finish) + .build() + ) + + # Run workflow - this creates checkpoints at each superstep + _ = [event async for event in workflow.run("hello", stream=True)] + + # Get all checkpoints sorted by timestamp + checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow.name), key=lambda c: c.timestamp) + + # Should have multiple checkpoints (one initial + one per superstep) + assert len(checkpoints) >= 2, f"Expected at least 2 checkpoints, got {len(checkpoints)}" + + # Verify chaining: first checkpoint has no previous + assert checkpoints[0].previous_checkpoint_id is None + + # Subsequent checkpoints should chain to the previous one + for i in range(1, len(checkpoints)): + assert checkpoints[i].previous_checkpoint_id == checkpoints[i - 1].checkpoint_id, ( + f"Checkpoint {i} should chain to checkpoint {i - 1}" + ) + + +async def test_memory_checkpoint_storage_roundtrip_json_native_types(): + """Test that JSON-native types (str, int, float, bool, None) roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "string": "hello world", + "integer": 42, + "negative_int": -100, + "float": 3.14159, + "negative_float": -2.71828, + "bool_true": True, + "bool_false": False, + "null_value": None, + "zero": 0, + "empty_string": "", + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state == checkpoint.state + + +async def test_memory_checkpoint_storage_roundtrip_datetime(): + """Test that datetime objects roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + now = datetime.now(timezone.utc) + specific_datetime = datetime(2025, 6, 15, 10, 30, 45, 123456, tzinfo=timezone.utc) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "current_time": now, + "specific_time": specific_datetime, + "nested": {"created_at": now, "updated_at": specific_datetime}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["current_time"] == now + assert loaded.state["specific_time"] == specific_datetime + assert loaded.state["nested"]["created_at"] == now + assert loaded.state["nested"]["updated_at"] == specific_datetime + + +async def test_memory_checkpoint_storage_roundtrip_dataclass(): + """Test that dataclass objects roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"]) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "custom_data": custom_obj, + "nested": {"inner_data": custom_obj}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["custom_data"] == custom_obj + assert loaded.state["custom_data"].name == "test" + assert loaded.state["custom_data"].value == 42 + assert loaded.state["custom_data"].tags == ["a", "b", "c"] + assert loaded.state["nested"]["inner_data"] == custom_obj + assert isinstance(loaded.state["custom_data"], _TestCustomData) + + +async def test_memory_checkpoint_storage_roundtrip_tuple_and_set(): + """Test that tuples and frozensets roundtrip correctly (type preserved in memory).""" + storage = InMemoryCheckpointStorage() + + original_tuple = (1, "two", 3.0, None) + original_frozenset = frozenset({1, 2, 3}) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "my_tuple": original_tuple, + "my_frozenset": original_frozenset, + "nested_tuple": {"inner": (10, 20, 30)}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # In-memory storage preserves exact types (no JSON serialization) + assert loaded.state["my_tuple"] == original_tuple + assert isinstance(loaded.state["my_tuple"], tuple) + assert loaded.state["my_frozenset"] == original_frozenset + assert isinstance(loaded.state["my_frozenset"], frozenset) + assert loaded.state["nested_tuple"]["inner"] == (10, 20, 30) + assert isinstance(loaded.state["nested_tuple"]["inner"], tuple) + + +async def test_memory_checkpoint_storage_roundtrip_complex_nested_structures(): + """Test complex nested structures with mixed types roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + # Create complex nested structure mixing JSON-native and non-native types + complex_state = { + "level1": { + "level2": { + "level3": { + "deep_string": "hello", + "deep_int": 123, + "deep_datetime": datetime(2025, 1, 1, tzinfo=timezone.utc), + "deep_tuple": (1, 2, 3), + } + }, + "list_of_dicts": [ + {"a": 1, "b": datetime(2025, 2, 1, tzinfo=timezone.utc)}, + {"c": 2, "d": (4, 5, 6)}, + ], + }, + "mixed_list": [ + "string", + 42, + 3.14, + True, + None, + datetime(2025, 3, 1, tzinfo=timezone.utc), + (7, 8, 9), + ], + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state=complex_state, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify deep nested values + assert loaded.state["level1"]["level2"]["level3"]["deep_string"] == "hello" + assert loaded.state["level1"]["level2"]["level3"]["deep_int"] == 123 + assert loaded.state["level1"]["level2"]["level3"]["deep_datetime"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded.state["level1"]["level2"]["level3"]["deep_tuple"] == (1, 2, 3) + assert isinstance(loaded.state["level1"]["level2"]["level3"]["deep_tuple"], tuple) + + # Verify list of dicts + assert loaded.state["level1"]["list_of_dicts"][0]["a"] == 1 + assert loaded.state["level1"]["list_of_dicts"][0]["b"] == datetime(2025, 2, 1, tzinfo=timezone.utc) + assert loaded.state["level1"]["list_of_dicts"][1]["d"] == (4, 5, 6) + assert isinstance(loaded.state["level1"]["list_of_dicts"][1]["d"], tuple) + + # Verify mixed list with correct types + assert loaded.state["mixed_list"][0] == "string" + assert loaded.state["mixed_list"][1] == 42 + assert loaded.state["mixed_list"][5] == datetime(2025, 3, 1, tzinfo=timezone.utc) + assert loaded.state["mixed_list"][6] == (7, 8, 9) + assert isinstance(loaded.state["mixed_list"][6], tuple) + + +async def test_memory_checkpoint_storage_roundtrip_messages_with_complex_data(): + """Test that messages dict with Message objects roundtrips correctly.""" + storage = InMemoryCheckpointStorage() + + msg1 = WorkflowMessage( + data={"text": "hello", "timestamp": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + source_id="source", + target_id="target", + ) + msg2 = WorkflowMessage( + data=(1, 2, 3), + source_id="s2", + target_id=None, + ) + msg3 = WorkflowMessage( + data="simple string", + source_id="s3", + target_id="t3", + ) + + messages = { + "executor1": [msg1, msg2], + "executor2": [msg3], + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + messages=messages, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify messages structure and types + assert len(loaded.messages["executor1"]) == 2 + loaded_msg1 = loaded.messages["executor1"][0] + loaded_msg2 = loaded.messages["executor1"][1] + loaded_msg3 = loaded.messages["executor2"][0] + + # Verify Message type is preserved + assert isinstance(loaded_msg1, WorkflowMessage) + assert isinstance(loaded_msg2, WorkflowMessage) + assert isinstance(loaded_msg3, WorkflowMessage) + + # Verify Message fields + assert loaded_msg1.data["text"] == "hello" + assert loaded_msg1.data["timestamp"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded_msg1.source_id == "source" + assert loaded_msg1.target_id == "target" + + assert loaded_msg2.data == (1, 2, 3) + assert isinstance(loaded_msg2.data, tuple) + assert loaded_msg2.source_id == "s2" + assert loaded_msg2.target_id is None + + assert loaded_msg3.data == "simple string" + assert loaded_msg3.source_id == "s3" + assert loaded_msg3.target_id == "t3" + + +async def test_memory_checkpoint_storage_roundtrip_pending_request_info_events(): + """Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + # Create request_info events using the proper WorkflowEvent factory + event1 = WorkflowEvent.request_info( + request_id="req123", + source_executor_id="executor1", + request_data="What is your name?", + response_type=str, + ) + event2 = WorkflowEvent.request_info( + request_id="req456", + source_executor_id="executor2", + request_data=_TestToolApprovalRequest( + tool_name="search", + arguments={"query": "test"}, + timestamp=datetime(2025, 1, 1, tzinfo=timezone.utc), + ), + response_type=bool, + ) + + pending_events = { + "req123": event1, + "req456": event2, + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + pending_request_info_events=pending_events, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify WorkflowEvent type is preserved + loaded_event1 = loaded.pending_request_info_events["req123"] + loaded_event2 = loaded.pending_request_info_events["req456"] + + assert isinstance(loaded_event1, WorkflowEvent) + assert isinstance(loaded_event2, WorkflowEvent) + + # Verify event1 fields + assert loaded_event1.type == "request_info" + assert loaded_event1.request_id == "req123" + assert loaded_event1.source_executor_id == "executor1" + assert loaded_event1.data == "What is your name?" + assert loaded_event1.response_type is str + + # Verify event2 fields with complex data + assert loaded_event2.type == "request_info" + assert loaded_event2.request_id == "req456" + assert loaded_event2.source_executor_id == "executor2" + assert isinstance(loaded_event2.data, _TestToolApprovalRequest) + assert loaded_event2.data.tool_name == "search" + assert loaded_event2.data.arguments == {"query": "test"} + assert loaded_event2.data.timestamp == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded_event2.response_type is bool + + +async def test_memory_checkpoint_storage_roundtrip_full_checkpoint(): + """Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types.""" + storage = InMemoryCheckpointStorage() + + # Create proper WorkflowMessage objects + msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t") + msg2 = WorkflowMessage(data=datetime(2025, 1, 1, tzinfo=timezone.utc), source_id="a", target_id="b") + + # Create proper WorkflowEvent for pending request + pending_event = WorkflowEvent.request_info( + request_id="req1", + source_executor_id="exec1", + request_data=_TestApprovalRequest(action="approve", params=(1, 2, 3)), + response_type=bool, + ) + + checkpoint = WorkflowCheckpoint( + checkpoint_id="full-test-checkpoint", + workflow_name="comprehensive-test", + graph_signature_hash="hash-abc123", + previous_checkpoint_id="previous-checkpoint-id", + timestamp=datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc).isoformat(), + messages={ + "exec1": [msg1], + "exec2": [msg2], + }, + state={ + "user_data": {"name": "test", "created": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + "_executor_state": { + "exec1": _TestExecutorState(counter=5, history=["a", "b", "c"]), + }, + }, + pending_request_info_events={ + "req1": pending_event, + }, + iteration_count=10, + metadata={ + "superstep": 5, + "started_at": datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc), + }, + version="1.0", + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify all scalar fields + assert loaded.checkpoint_id == checkpoint.checkpoint_id + assert loaded.workflow_name == checkpoint.workflow_name + assert loaded.graph_signature_hash == checkpoint.graph_signature_hash + assert loaded.previous_checkpoint_id == checkpoint.previous_checkpoint_id + assert loaded.timestamp == checkpoint.timestamp + assert loaded.iteration_count == checkpoint.iteration_count + assert loaded.version == checkpoint.version + + # Verify complex nested state data + assert loaded.state["user_data"]["created"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded.state["_executor_state"]["exec1"].counter == 5 + assert loaded.state["_executor_state"]["exec1"].history == ["a", "b", "c"] + assert isinstance(loaded.state["_executor_state"]["exec1"], _TestExecutorState) + + # Verify messages are proper Message objects + loaded_msg1 = loaded.messages["exec1"][0] + loaded_msg2 = loaded.messages["exec2"][0] + assert isinstance(loaded_msg1, WorkflowMessage) + assert isinstance(loaded_msg2, WorkflowMessage) + assert loaded_msg1.data == "msg1" + assert loaded_msg1.source_id == "s" + assert loaded_msg2.data == datetime(2025, 1, 1, tzinfo=timezone.utc) + + # Verify pending events are proper WorkflowEvent objects + loaded_event = loaded.pending_request_info_events["req1"] + assert isinstance(loaded_event, WorkflowEvent) + assert loaded_event.type == "request_info" + assert loaded_event.request_id == "req1" + assert isinstance(loaded_event.data, _TestApprovalRequest) + assert loaded_event.data.params == (1, 2, 3) + + # Verify metadata + assert loaded.metadata["superstep"] == 5 + assert loaded.metadata["started_at"] == datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc) + + +async def test_memory_checkpoint_storage_roundtrip_bytes(): + """Test that bytes objects roundtrip correctly.""" + storage = InMemoryCheckpointStorage() + + binary_data = b"\x00\x01\x02\xff\xfe\xfd" + unicode_bytes = "Hello 世界".encode() + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "binary_data": binary_data, + "unicode_bytes": unicode_bytes, + "nested": {"inner_bytes": binary_data}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["binary_data"] == binary_data + assert loaded.state["unicode_bytes"] == unicode_bytes + assert loaded.state["nested"]["inner_bytes"] == binary_data + assert isinstance(loaded.state["binary_data"], bytes) + + +async def test_memory_checkpoint_storage_roundtrip_empty_collections(): + """Test that empty collections roundtrip correctly (types preserved in memory).""" + storage = InMemoryCheckpointStorage() + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "empty_dict": {}, + "empty_list": [], + "empty_tuple": (), + "nested_empty": {"inner_dict": {}, "inner_list": []}, + }, + messages={}, + pending_request_info_events={}, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["empty_dict"] == {} + assert loaded.state["empty_list"] == [] + # In-memory storage preserves exact types (no JSON serialization) + assert loaded.state["empty_tuple"] == () + assert isinstance(loaded.state["empty_tuple"], tuple) + assert loaded.state["nested_empty"]["inner_dict"] == {} + assert loaded.messages == {} + assert loaded.pending_request_info_events == {} + + +# endregion + +# region FileCheckpointStorage + + async def test_file_checkpoint_storage_save_and_load(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) checkpoint = WorkflowCheckpoint( - workflow_id="test-workflow", + workflow_name="test-workflow", + graph_signature_hash="test-hash", messages={"executor1": [{"data": "hello", "source_id": "test", "target_id": None}]}, state={"key": "value"}, pending_request_info_events={"req123": {"data": "test"}}, ) # Save checkpoint - saved_id = await storage.save_checkpoint(checkpoint) + saved_id = await storage.save(checkpoint) assert saved_id == checkpoint.checkpoint_id # Verify file was created @@ -172,10 +790,11 @@ async def test_file_checkpoint_storage_save_and_load(): assert file_path.exists() # Load checkpoint - loaded_checkpoint = await storage.load_checkpoint(checkpoint.checkpoint_id) + loaded_checkpoint = await storage.load(checkpoint.checkpoint_id) assert loaded_checkpoint is not None assert loaded_checkpoint.checkpoint_id == checkpoint.checkpoint_id - assert loaded_checkpoint.workflow_id == checkpoint.workflow_id + assert loaded_checkpoint.workflow_name == checkpoint.workflow_name + assert loaded_checkpoint.graph_signature_hash == checkpoint.graph_signature_hash assert loaded_checkpoint.messages == checkpoint.messages assert loaded_checkpoint.state == checkpoint.state assert loaded_checkpoint.pending_request_info_events == checkpoint.pending_request_info_events @@ -185,72 +804,64 @@ async def test_file_checkpoint_storage_load_nonexistent(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) - result = await storage.load_checkpoint("nonexistent-id") - assert result is None + with pytest.raises(WorkflowCheckpointException): + await storage.load("nonexistent-id") -async def test_file_checkpoint_storage_list_checkpoints(): +async def test_file_checkpoint_storage_list(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) # Create checkpoints for different workflows - checkpoint1 = WorkflowCheckpoint(workflow_id="workflow-1") - checkpoint2 = WorkflowCheckpoint(workflow_id="workflow-1") - checkpoint3 = WorkflowCheckpoint(workflow_id="workflow-2") + checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1") + checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2") + checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3") - await storage.save_checkpoint(checkpoint1) - await storage.save_checkpoint(checkpoint2) - await storage.save_checkpoint(checkpoint3) + await storage.save(checkpoint1) + await storage.save(checkpoint2) + await storage.save(checkpoint3) - # Test list_checkpoint_ids for workflow-1 - workflow1_checkpoint_ids = await storage.list_checkpoint_ids("workflow-1") + # Test list_ids for workflow-1 + workflow1_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-1") assert len(workflow1_checkpoint_ids) == 2 assert checkpoint1.checkpoint_id in workflow1_checkpoint_ids assert checkpoint2.checkpoint_id in workflow1_checkpoint_ids - # Test list_checkpoints for workflow-1 (returns objects) - workflow1_checkpoints = await storage.list_checkpoints("workflow-1") + # Test list for workflow-1 (returns objects) + workflow1_checkpoints = await storage.list_checkpoints(workflow_name="workflow-1") assert len(workflow1_checkpoints) == 2 assert all(isinstance(cp, WorkflowCheckpoint) for cp in workflow1_checkpoints) checkpoint_ids = {cp.checkpoint_id for cp in workflow1_checkpoints} assert checkpoint_ids == {checkpoint1.checkpoint_id, checkpoint2.checkpoint_id} - # Test list_checkpoint_ids for workflow-2 - workflow2_checkpoint_ids = await storage.list_checkpoint_ids("workflow-2") + # Test list_ids for workflow-2 + workflow2_checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="workflow-2") assert len(workflow2_checkpoint_ids) == 1 assert checkpoint3.checkpoint_id in workflow2_checkpoint_ids - # Test list_checkpoints for workflow-2 (returns objects) - workflow2_checkpoints = await storage.list_checkpoints("workflow-2") + # Test list for workflow-2 (returns objects) + workflow2_checkpoints = await storage.list_checkpoints(workflow_name="workflow-2") assert len(workflow2_checkpoints) == 1 assert workflow2_checkpoints[0].checkpoint_id == checkpoint3.checkpoint_id - # Test list all checkpoints - all_checkpoint_ids = await storage.list_checkpoint_ids() - assert len(all_checkpoint_ids) == 3 - - all_checkpoints = await storage.list_checkpoints() - assert len(all_checkpoints) == 3 - assert all(isinstance(cp, WorkflowCheckpoint) for cp in all_checkpoints) - async def test_file_checkpoint_storage_delete(): with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) - checkpoint = WorkflowCheckpoint(workflow_id="test-workflow") + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") # Save checkpoint - await storage.save_checkpoint(checkpoint) + await storage.save(checkpoint) file_path = Path(temp_dir) / f"{checkpoint.checkpoint_id}.json" assert file_path.exists() # Delete checkpoint - result = await storage.delete_checkpoint(checkpoint.checkpoint_id) + result = await storage.delete(checkpoint.checkpoint_id) assert result is True assert not file_path.exists() # Try to delete again - result = await storage.delete_checkpoint(checkpoint.checkpoint_id) + result = await storage.delete(checkpoint.checkpoint_id) assert result is False @@ -264,8 +875,8 @@ async def test_file_checkpoint_storage_directory_creation(): assert nested_path.is_dir() # Should be able to save checkpoints - checkpoint = WorkflowCheckpoint(workflow_id="test") - await storage.save_checkpoint(checkpoint) + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") + await storage.save(checkpoint) file_path = nested_path / f"{checkpoint.checkpoint_id}.json" assert file_path.exists() @@ -280,8 +891,8 @@ async def test_file_checkpoint_storage_corrupted_file(): with open(corrupted_file, "w") as f: # noqa: ASYNC230 f.write("{ invalid json }") - # list_checkpoints should handle the corrupted file gracefully - checkpoints = await storage.list_checkpoints("any-workflow") + # list should handle the corrupted file gracefully + checkpoints = await storage.list_checkpoints(workflow_name="any-workflow") assert checkpoints == [] @@ -291,15 +902,16 @@ async def test_file_checkpoint_storage_json_serialization(): # Create checkpoint with complex nested data checkpoint = WorkflowCheckpoint( - workflow_id="complex-workflow", + workflow_name="test-workflow", + graph_signature_hash="test-hash", messages={"executor1": [{"data": {"nested": {"value": 42}}, "source_id": "test", "target_id": None}]}, state={"list": [1, 2, 3], "dict": {"a": "b", "c": {"d": "e"}}, "bool": True, "null": None}, pending_request_info_events={"req123": {"data": "test"}}, ) # Save and load - await storage.save_checkpoint(checkpoint) - loaded = await storage.load_checkpoint(checkpoint.checkpoint_id) + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) assert loaded is not None assert loaded.messages == checkpoint.messages @@ -317,22 +929,513 @@ async def test_file_checkpoint_storage_json_serialization(): assert data["pending_request_info_events"]["req123"]["data"] == "test" -def test_checkpoint_storage_protocol_compliance(): - # This test ensures both implementations have all required methods - memory_storage = InMemoryCheckpointStorage() +async def test_file_checkpoint_storage_get_latest(): + import asyncio with tempfile.TemporaryDirectory() as temp_dir: - file_storage = FileCheckpointStorage(temp_dir) + storage = FileCheckpointStorage(temp_dir) - for storage in [memory_storage, file_storage]: - # Test that all protocol methods exist and are callable - assert hasattr(storage, "save_checkpoint") - assert callable(storage.save_checkpoint) - assert hasattr(storage, "load_checkpoint") - assert callable(storage.load_checkpoint) - assert hasattr(storage, "list_checkpoint_ids") - assert callable(storage.list_checkpoint_ids) - assert hasattr(storage, "list_checkpoints") - assert callable(storage.list_checkpoints) - assert hasattr(storage, "delete_checkpoint") - assert callable(storage.delete_checkpoint) + # Create checkpoints with small delays to ensure different timestamps + checkpoint1 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-1") + await asyncio.sleep(0.01) + checkpoint2 = WorkflowCheckpoint(workflow_name="workflow-1", graph_signature_hash="hash-2") + await asyncio.sleep(0.01) + checkpoint3 = WorkflowCheckpoint(workflow_name="workflow-2", graph_signature_hash="hash-3") + + await storage.save(checkpoint1) + await storage.save(checkpoint2) + await storage.save(checkpoint3) + + # Test get_latest for workflow-1 + latest = await storage.get_latest(workflow_name="workflow-1") + assert latest is not None + assert latest.checkpoint_id == checkpoint2.checkpoint_id + + # Test get_latest for workflow-2 + latest2 = await storage.get_latest(workflow_name="workflow-2") + assert latest2 is not None + assert latest2.checkpoint_id == checkpoint3.checkpoint_id + + # Test get_latest for non-existent workflow + latest_none = await storage.get_latest(workflow_name="nonexistent-workflow") + assert latest_none is None + + +async def test_file_checkpoint_storage_list_ids_corrupted_file(): + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Create a valid checkpoint first + checkpoint = WorkflowCheckpoint(workflow_name="test-workflow", graph_signature_hash="test-hash") + await storage.save(checkpoint) + + # Create a corrupted JSON file + corrupted_file = Path(temp_dir) / "corrupted.json" + with open(corrupted_file, "w") as f: # noqa: ASYNC230 + f.write("{ invalid json }") + + # list_ids should handle the corrupted file gracefully + checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="test-workflow") + assert len(checkpoint_ids) == 1 + assert checkpoint.checkpoint_id in checkpoint_ids + + +async def test_file_checkpoint_storage_list_ids_empty(): + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Test list_ids on empty storage + checkpoint_ids = await storage.list_checkpoint_ids(workflow_name="any-workflow") + assert checkpoint_ids == [] + + +async def test_file_checkpoint_storage_roundtrip_json_native_types(): + """Test that JSON-native types (str, int, float, bool, None) roundtrip correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "string": "hello world", + "integer": 42, + "negative_int": -100, + "float": 3.14159, + "negative_float": -2.71828, + "bool_true": True, + "bool_false": False, + "null_value": None, + "zero": 0, + "empty_string": "", + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state == checkpoint.state + + +async def test_file_checkpoint_storage_roundtrip_datetime(): + """Test that datetime objects roundtrip correctly via pickle encoding.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + now = datetime.now(timezone.utc) + specific_datetime = datetime(2025, 6, 15, 10, 30, 45, 123456, tzinfo=timezone.utc) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "current_time": now, + "specific_time": specific_datetime, + "nested": {"created_at": now, "updated_at": specific_datetime}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["current_time"] == now + assert loaded.state["specific_time"] == specific_datetime + assert loaded.state["nested"]["created_at"] == now + assert loaded.state["nested"]["updated_at"] == specific_datetime + + +async def test_file_checkpoint_storage_roundtrip_dataclass(): + """Test that dataclass objects roundtrip correctly via pickle encoding.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + custom_obj = _TestCustomData(name="test", value=42, tags=["a", "b", "c"]) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "custom_data": custom_obj, + "nested": {"inner_data": custom_obj}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["custom_data"] == custom_obj + assert loaded.state["custom_data"].name == "test" + assert loaded.state["custom_data"].value == 42 + assert loaded.state["custom_data"].tags == ["a", "b", "c"] + assert loaded.state["nested"]["inner_data"] == custom_obj + assert isinstance(loaded.state["custom_data"], _TestCustomData) + + +async def test_file_checkpoint_storage_roundtrip_tuple_and_set(): + """Test tuple/frozenset encoding behavior. + + Tuples, sets, and frozensets are pickled to preserve their type through + the encode/decode roundtrip. + """ + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + original_tuple = (1, "two", 3.0, None) + original_frozenset = frozenset({1, 2, 3}) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "my_tuple": original_tuple, + "my_frozenset": original_frozenset, + "nested_tuple": {"inner": (10, 20, 30)}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Tuples preserve their type through roundtrip + assert loaded.state["my_tuple"] == original_tuple + assert isinstance(loaded.state["my_tuple"], tuple) + + # Frozensets are pickled and preserve their type + assert loaded.state["my_frozenset"] == original_frozenset + assert isinstance(loaded.state["my_frozenset"], frozenset) + + # Nested tuples also preserve their type + assert loaded.state["nested_tuple"]["inner"] == (10, 20, 30) + assert isinstance(loaded.state["nested_tuple"]["inner"], tuple) + + +async def test_file_checkpoint_storage_roundtrip_complex_nested_structures(): + """Test complex nested structures with mixed types roundtrip correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Create complex nested structure mixing JSON-native and non-native types + complex_state = { + "level1": { + "level2": { + "level3": { + "deep_string": "hello", + "deep_int": 123, + "deep_datetime": datetime(2025, 1, 1, tzinfo=timezone.utc), + "deep_tuple": (1, 2, 3), + } + }, + "list_of_dicts": [ + {"a": 1, "b": datetime(2025, 2, 1, tzinfo=timezone.utc)}, + {"c": 2, "d": (4, 5, 6)}, + ], + }, + "mixed_list": [ + "string", + 42, + 3.14, + True, + None, + datetime(2025, 3, 1, tzinfo=timezone.utc), + (7, 8, 9), + ], + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state=complex_state, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify deep nested values + assert loaded.state["level1"]["level2"]["level3"]["deep_string"] == "hello" + assert loaded.state["level1"]["level2"]["level3"]["deep_int"] == 123 + assert loaded.state["level1"]["level2"]["level3"]["deep_datetime"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + # Tuples preserve their type through roundtrip + assert loaded.state["level1"]["level2"]["level3"]["deep_tuple"] == (1, 2, 3) + + # Verify list of dicts + assert loaded.state["level1"]["list_of_dicts"][0]["a"] == 1 + assert loaded.state["level1"]["list_of_dicts"][0]["b"] == datetime(2025, 2, 1, tzinfo=timezone.utc) + # Tuples preserve their type through roundtrip + assert loaded.state["level1"]["list_of_dicts"][1]["d"] == (4, 5, 6) + + # Verify mixed list with correct types + assert loaded.state["mixed_list"][0] == "string" + assert loaded.state["mixed_list"][1] == 42 + assert loaded.state["mixed_list"][5] == datetime(2025, 3, 1, tzinfo=timezone.utc) + # Tuples preserve their type through roundtrip + assert loaded.state["mixed_list"][6] == (7, 8, 9) + assert isinstance(loaded.state["mixed_list"][6], tuple) + + +async def test_file_checkpoint_storage_roundtrip_messages_with_complex_data(): + """Test that messages dict with Message objects roundtrips correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + msg1 = WorkflowMessage( + data={"text": "hello", "timestamp": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + source_id="source", + target_id="target", + ) + msg2 = WorkflowMessage( + data=(1, 2, 3), + source_id="s2", + target_id=None, + ) + msg3 = WorkflowMessage( + data="simple string", + source_id="s3", + target_id="t3", + ) + + messages = { + "executor1": [msg1, msg2], + "executor2": [msg3], + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + messages=messages, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify messages structure and types + assert len(loaded.messages["executor1"]) == 2 + loaded_msg1 = loaded.messages["executor1"][0] + loaded_msg2 = loaded.messages["executor1"][1] + loaded_msg3 = loaded.messages["executor2"][0] + + # Verify WorkflowMessage type is preserved + assert isinstance(loaded_msg1, WorkflowMessage) + assert isinstance(loaded_msg2, WorkflowMessage) + assert isinstance(loaded_msg3, WorkflowMessage) + + # Verify WorkflowMessage fields + assert loaded_msg1.data["text"] == "hello" + assert loaded_msg1.data["timestamp"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded_msg1.source_id == "source" + assert loaded_msg1.target_id == "target" + + assert loaded_msg2.data == (1, 2, 3) + assert isinstance(loaded_msg2.data, tuple) + assert loaded_msg2.source_id == "s2" + assert loaded_msg2.target_id is None + + assert loaded_msg3.data == "simple string" + assert loaded_msg3.source_id == "s3" + assert loaded_msg3.target_id == "t3" + + +async def test_file_checkpoint_storage_roundtrip_pending_request_info_events(): + """Test that pending_request_info_events with WorkflowEvent objects roundtrip correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Create request_info events using the proper WorkflowEvent factory + event1 = WorkflowEvent.request_info( + request_id="req123", + source_executor_id="executor1", + request_data="What is your name?", + response_type=str, + ) + event2 = WorkflowEvent.request_info( + request_id="req456", + source_executor_id="executor2", + request_data=_TestToolApprovalRequest( + tool_name="search", + arguments={"query": "test"}, + timestamp=datetime(2025, 1, 1, tzinfo=timezone.utc), + ), + response_type=bool, + ) + + pending_events = { + "req123": event1, + "req456": event2, + } + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + pending_request_info_events=pending_events, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify WorkflowEvent type is preserved + loaded_event1 = loaded.pending_request_info_events["req123"] + loaded_event2 = loaded.pending_request_info_events["req456"] + + assert isinstance(loaded_event1, WorkflowEvent) + assert isinstance(loaded_event2, WorkflowEvent) + + # Verify event1 fields + assert loaded_event1.type == "request_info" + assert loaded_event1.request_id == "req123" + assert loaded_event1.source_executor_id == "executor1" + assert loaded_event1.data == "What is your name?" + assert loaded_event1.response_type is str + + # Verify event2 fields with complex data + assert loaded_event2.type == "request_info" + assert loaded_event2.request_id == "req456" + assert loaded_event2.source_executor_id == "executor2" + assert isinstance(loaded_event2.data, _TestToolApprovalRequest) + assert loaded_event2.data.tool_name == "search" + assert loaded_event2.data.arguments == {"query": "test"} + assert loaded_event2.data.timestamp == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded_event2.response_type is bool + + +async def test_file_checkpoint_storage_roundtrip_full_checkpoint(): + """Test complete WorkflowCheckpoint roundtrip with all fields populated using proper types.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Create proper WorkflowMessage objects + msg1 = WorkflowMessage(data="msg1", source_id="s", target_id="t") + msg2 = WorkflowMessage(data=datetime(2025, 1, 1, tzinfo=timezone.utc), source_id="a", target_id="b") + + # Create proper WorkflowEvent for pending request + pending_event = WorkflowEvent.request_info( + request_id="req1", + source_executor_id="exec1", + request_data=_TestApprovalRequest(action="approve", params=(1, 2, 3)), + response_type=bool, + ) + + checkpoint = WorkflowCheckpoint( + checkpoint_id="full-test-checkpoint", + workflow_name="comprehensive-test", + graph_signature_hash="hash-abc123", + previous_checkpoint_id="previous-checkpoint-id", + timestamp=datetime(2025, 6, 15, 12, 0, 0, tzinfo=timezone.utc).isoformat(), + messages={ + "exec1": [msg1], + "exec2": [msg2], + }, + state={ + "user_data": {"name": "test", "created": datetime(2025, 1, 1, tzinfo=timezone.utc)}, + "_executor_state": { + "exec1": _TestExecutorState(counter=5, history=["a", "b", "c"]), + }, + }, + pending_request_info_events={ + "req1": pending_event, + }, + iteration_count=10, + metadata={ + "superstep": 5, + "started_at": datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc), + }, + version="1.0", + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + # Verify all scalar fields + assert loaded.checkpoint_id == checkpoint.checkpoint_id + assert loaded.workflow_name == checkpoint.workflow_name + assert loaded.graph_signature_hash == checkpoint.graph_signature_hash + assert loaded.previous_checkpoint_id == checkpoint.previous_checkpoint_id + assert loaded.timestamp == checkpoint.timestamp + assert loaded.iteration_count == checkpoint.iteration_count + assert loaded.version == checkpoint.version + + # Verify complex nested state data + assert loaded.state["user_data"]["created"] == datetime(2025, 1, 1, tzinfo=timezone.utc) + assert loaded.state["_executor_state"]["exec1"].counter == 5 + assert loaded.state["_executor_state"]["exec1"].history == ["a", "b", "c"] + assert isinstance(loaded.state["_executor_state"]["exec1"], _TestExecutorState) + + # Verify messages are proper Message objects + loaded_msg1 = loaded.messages["exec1"][0] + loaded_msg2 = loaded.messages["exec2"][0] + assert isinstance(loaded_msg1, WorkflowMessage) + assert isinstance(loaded_msg2, WorkflowMessage) + assert loaded_msg1.data == "msg1" + assert loaded_msg1.source_id == "s" + assert loaded_msg2.data == datetime(2025, 1, 1, tzinfo=timezone.utc) + + # Verify pending events are proper WorkflowEvent objects + loaded_event = loaded.pending_request_info_events["req1"] + assert isinstance(loaded_event, WorkflowEvent) + assert loaded_event.type == "request_info" + assert loaded_event.request_id == "req1" + assert isinstance(loaded_event.data, _TestApprovalRequest) + assert loaded_event.data.params == (1, 2, 3) + + # Verify metadata + assert loaded.metadata["superstep"] == 5 + assert loaded.metadata["started_at"] == datetime(2025, 6, 15, 11, 0, 0, tzinfo=timezone.utc) + + +async def test_file_checkpoint_storage_roundtrip_bytes(): + """Test that bytes objects roundtrip correctly via pickle encoding.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + binary_data = b"\x00\x01\x02\xff\xfe\xfd" + unicode_bytes = "Hello 世界".encode() + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "binary_data": binary_data, + "unicode_bytes": unicode_bytes, + "nested": {"inner_bytes": binary_data}, + }, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["binary_data"] == binary_data + assert loaded.state["unicode_bytes"] == unicode_bytes + assert loaded.state["nested"]["inner_bytes"] == binary_data + assert isinstance(loaded.state["binary_data"], bytes) + + +async def test_file_checkpoint_storage_roundtrip_empty_collections(): + """Test that empty collections roundtrip correctly.""" + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + checkpoint = WorkflowCheckpoint( + workflow_name="test-workflow", + graph_signature_hash="test-hash", + state={ + "empty_dict": {}, + "empty_list": [], + "empty_tuple": (), + "nested_empty": {"inner_dict": {}, "inner_list": []}, + }, + messages={}, + pending_request_info_events={}, + ) + + await storage.save(checkpoint) + loaded = await storage.load(checkpoint.checkpoint_id) + + assert loaded.state["empty_dict"] == {} + assert loaded.state["empty_list"] == [] + # Empty tuples preserve their type through roundtrip + assert loaded.state["empty_tuple"] == () + assert isinstance(loaded.state["empty_tuple"], tuple) + assert loaded.state["nested_empty"]["inner_dict"] == {} + assert loaded.messages == {} + assert loaded.pending_request_info_events == {} + + +# endregion diff --git a/python/packages/core/tests/workflow/test_checkpoint_decode.py b/python/packages/core/tests/workflow/test_checkpoint_decode.py index 431c70cc3c..5ef9bc480f 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_decode.py +++ b/python/packages/core/tests/workflow/test_checkpoint_decode.py @@ -1,16 +1,17 @@ # Copyright (c) Microsoft. All rights reserved. -from dataclasses import dataclass # noqa: I001 +from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any, cast +import pytest from agent_framework._workflows._checkpoint_encoding import ( - DATACLASS_MARKER, - MODEL_MARKER, + _TYPE_MARKER, # type: ignore + CheckpointDecodingError, decode_checkpoint_value, encode_checkpoint_value, ) -from agent_framework._workflows._typing_utils import is_instance_of @dataclass @@ -30,7 +31,22 @@ class SampleResponse: request_id: str -def test_decode_dataclass_with_nested_request() -> None: +# --- Tests for round-trip encode/decode --- + + +def test_roundtrip_simple_dataclass() -> None: + """Test encoding and decoding of a simple dataclass.""" + original = SampleRequest(request_id="test-123", prompt="test prompt") + + encoded = encode_checkpoint_value(original) + decoded = cast(SampleRequest, decode_checkpoint_value(encoded)) + + assert isinstance(decoded, SampleRequest) + assert decoded.request_id == "test-123" + assert decoded.prompt == "test prompt" + + +def test_roundtrip_dataclass_with_nested_request() -> None: """Test that dataclass with nested dataclass fields can be encoded and decoded correctly.""" original = SampleResponse( data="approve", @@ -49,45 +65,7 @@ def test_decode_dataclass_with_nested_request() -> None: assert decoded.original_request.request_id == "abc" -def test_is_instance_of_coerces_nested_dataclass_dict() -> None: - """Test that is_instance_of can handle nested structures with dict conversion.""" - response = SampleResponse( - data="approve", - original_request=SampleRequest(request_id="req-1", prompt="prompt"), - request_id="req-1", - ) - - # Simulate checkpoint decode fallback leaving a dict - response.original_request = cast( - Any, - { - "request_id": "req-1", - "prompt": "prompt", - }, - ) - - assert is_instance_of(response, SampleResponse) - assert isinstance(response.original_request, dict) - - # Verify the dict contains expected values - dict_request = cast(dict[str, Any], response.original_request) - assert dict_request["request_id"] == "req-1" - assert dict_request["prompt"] == "prompt" - - -def test_encode_decode_simple_dataclass() -> None: - """Test encoding and decoding of a simple dataclass.""" - original = SampleRequest(request_id="test-123", prompt="test prompt") - - encoded = encode_checkpoint_value(original) - decoded = cast(SampleRequest, decode_checkpoint_value(encoded)) - - assert isinstance(decoded, SampleRequest) - assert decoded.request_id == "test-123" - assert decoded.prompt == "test prompt" - - -def test_encode_decode_nested_structures() -> None: +def test_roundtrip_nested_structures() -> None: """Test encoding and decoding of complex nested structures.""" nested_data = { "requests": [ @@ -110,7 +88,6 @@ def test_encode_decode_nested_structures() -> None: assert "requests" in decoded assert "responses" in decoded - # Check the requests list requests = cast(list[Any], decoded["requests"]) assert isinstance(requests, list) assert len(requests) == 2 @@ -120,7 +97,6 @@ def test_encode_decode_nested_structures() -> None: assert first_request.request_id == "req-1" assert second_request.request_id == "req-2" - # Check the responses dict responses = cast(dict[str, Any], decoded["responses"]) assert isinstance(responses, dict) assert "req-1" in responses @@ -131,108 +107,145 @@ def test_encode_decode_nested_structures() -> None: assert response.original_request.request_id == "req-1" -def test_encode_allows_marker_key_without_value_key() -> None: - """Test that encoding a dict with only the marker key (no 'value') is allowed.""" - dict_with_marker_only = { - MODEL_MARKER: "some.module:FakeClass", - "other_key": "test", +def test_roundtrip_datetime() -> None: + """Test round-trip encoding/decoding of datetime objects.""" + original = datetime(2024, 5, 4, 12, 30, 45, tzinfo=timezone.utc) + + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) + + assert isinstance(decoded, datetime) + assert decoded == original + + +def test_roundtrip_primitives() -> None: + """Test that primitive types round-trip unchanged.""" + for value in ["hello", 42, 3.14, True, False, None]: + assert decode_checkpoint_value(encode_checkpoint_value(value)) == value + + +def test_roundtrip_dict_with_mixed_values() -> None: + """Test round-trip of a dict containing both primitives and complex types.""" + original = { + "name": "test", + "request": SampleRequest(request_id="r1", prompt="p1"), + "count": 5, } - encoded = encode_checkpoint_value(dict_with_marker_only) - assert MODEL_MARKER in encoded - assert "other_key" in encoded + + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) + + assert decoded["name"] == "test" + assert decoded["count"] == 5 + assert isinstance(decoded["request"], SampleRequest) + assert decoded["request"].request_id == "r1" -def test_encode_allows_value_key_without_marker_key() -> None: - """Test that encoding a dict with only 'value' key (no marker) is allowed.""" - dict_with_value_only = { - "value": {"data": "test"}, - "other_key": "test", - } - encoded = encode_checkpoint_value(dict_with_value_only) - assert "value" in encoded - assert "other_key" in encoded +# --- Tests for decode primitives --- -def test_encode_allows_marker_with_value_key() -> None: - """Test that encoding a dict with marker and 'value' keys is allowed. - - This is allowed because legitimate encoded data may contain these keys, - and security is enforced at deserialization time by validating class types. - """ - dict_with_both = { - MODEL_MARKER: "some.module:SomeClass", - "value": {"data": "test"}, - "strategy": "to_dict", - } - encoded = encode_checkpoint_value(dict_with_both) - assert MODEL_MARKER in encoded - assert "value" in encoded +def test_decode_string() -> None: + """Test decoding a string passes through unchanged.""" + assert decode_checkpoint_value("hello") == "hello" -class NotADataclass: +def test_decode_integer() -> None: + """Test decoding an integer passes through unchanged.""" + assert decode_checkpoint_value(42) == 42 + + +def test_decode_none() -> None: + """Test decoding None passes through unchanged.""" + assert decode_checkpoint_value(None) is None + + +# --- Tests for decode collections --- + + +def test_decode_plain_dict() -> None: + """Test decoding a plain dictionary with primitive values.""" + data = {"a": 1, "b": "two"} + assert decode_checkpoint_value(data) == {"a": 1, "b": "two"} + + +def test_decode_plain_list() -> None: + """Test decoding a plain list with primitive values.""" + data = [1, "two", 3.0] + assert decode_checkpoint_value(data) == [1, "two", 3.0] + + +# --- Tests for type verification --- + + +def test_decode_raises_on_type_mismatch() -> None: + """Test that decoding raises CheckpointDecodingError when type doesn't match.""" + # Encode a SampleRequest but tamper with the type marker + encoded = encode_checkpoint_value(SampleRequest(request_id="r1", prompt="p1")) + assert isinstance(encoded, dict) + encoded[_TYPE_MARKER] = "nonexistent.module:FakeClass" + + with pytest.raises(CheckpointDecodingError, match="Type mismatch"): + decode_checkpoint_value(encoded) + + +class NotADataclass: # noqa: B903 """A regular class that is not a dataclass.""" def __init__(self, value: str) -> None: self.value = value - def get_value(self) -> str: - return self.value + +def test_roundtrip_regular_class() -> None: + """Test that regular (non-dataclass) objects can be round-tripped via pickle.""" + original = NotADataclass(value="test_value") + + encoded = encode_checkpoint_value(original) + decoded = cast(NotADataclass, decode_checkpoint_value(encoded)) + + assert isinstance(decoded, NotADataclass) + assert decoded.value == "test_value" -class NotAModel: - """A regular class that does not support the model protocol.""" +def test_roundtrip_tuple() -> None: + """Test that tuples preserve their type through encode/decode roundtrip.""" + original = (1, "two", 3.0) - def __init__(self, value: str) -> None: - self.value = value + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) - def get_value(self) -> str: - return self.value + assert isinstance(decoded, tuple) + assert decoded == original -def test_decode_rejects_non_dataclass_with_dataclass_marker() -> None: - """Test that decode returns raw value when marked class is not a dataclass.""" - # Manually construct a payload that claims NotADataclass is a dataclass - fake_payload = { - DATACLASS_MARKER: f"{NotADataclass.__module__}:{NotADataclass.__name__}", - "value": {"value": "test_value"}, - } +def test_roundtrip_set() -> None: + """Test that sets preserve their type through encode/decode roundtrip.""" + original = {1, 2, 3} - decoded = decode_checkpoint_value(fake_payload) + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) - # Should return the raw decoded value, not an instance of NotADataclass - assert isinstance(decoded, dict) - assert decoded["value"] == "test_value" + assert isinstance(decoded, set) + assert decoded == original -def test_decode_rejects_non_model_with_model_marker() -> None: - """Test that decode returns raw value when marked class doesn't support model protocol.""" - # Manually construct a payload that claims NotAModel supports the model protocol - fake_payload = { - MODEL_MARKER: f"{NotAModel.__module__}:{NotAModel.__name__}", - "strategy": "to_dict", - "value": {"value": "test_value"}, - } +def test_roundtrip_nested_tuple_in_dict() -> None: + """Test that tuples nested inside dicts preserve their type.""" + original = {"items": (1, 2, 3), "name": "test"} - decoded = decode_checkpoint_value(fake_payload) + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) - # Should return the raw decoded value, not an instance of NotAModel - assert isinstance(decoded, dict) - assert decoded["value"] == "test_value" + assert isinstance(decoded["items"], tuple) + assert decoded["items"] == (1, 2, 3) + assert decoded["name"] == "test" -def test_encode_allows_nested_dict_with_marker_keys() -> None: - """Test that encoding allows nested dicts containing marker patterns. +def test_roundtrip_set_in_list() -> None: + """Test that sets nested inside lists preserve their type.""" + original = [{"tags": {1, 2, 3}}] - Security is enforced at deserialization time, not serialization time, - so legitimate encoded data can contain markers at any nesting level. - """ - nested_data = { - "outer": { - MODEL_MARKER: "some.module:SomeClass", - "value": {"data": "test"}, - } - } + encoded = encode_checkpoint_value(original) + decoded = decode_checkpoint_value(encoded) - encoded = encode_checkpoint_value(nested_data) - assert "outer" in encoded - assert MODEL_MARKER in encoded["outer"] + assert isinstance(decoded[0]["tags"], set) + assert decoded[0]["tags"] == {1, 2, 3} diff --git a/python/packages/core/tests/workflow/test_checkpoint_encode.py b/python/packages/core/tests/workflow/test_checkpoint_encode.py index 3f4db1f864..68ec1ac4e3 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_encode.py +++ b/python/packages/core/tests/workflow/test_checkpoint_encode.py @@ -1,12 +1,13 @@ # Copyright (c) Microsoft. All rights reserved. +import json from dataclasses import dataclass +from datetime import datetime, timezone from typing import Any from agent_framework._workflows._checkpoint_encoding import ( - _CYCLE_SENTINEL, - DATACLASS_MARKER, - MODEL_MARKER, + _PICKLE_MARKER, + _TYPE_MARKER, encode_checkpoint_value, ) @@ -41,23 +42,6 @@ class ModelWithToDict: return cls(data=d["data"]) -class ModelWithToJson: - """A class that implements to_json/from_json protocol.""" - - def __init__(self, data: str) -> None: - self.data = data - - def to_json(self) -> str: - return f'{{"data": "{self.data}"}}' - - @classmethod - def from_json(cls, json_str: str) -> "ModelWithToJson": - import json - - d = json.loads(json_str) - return cls(data=d["data"]) - - class UnknownObject: """A class that doesn't support any serialization protocol.""" @@ -68,43 +52,37 @@ class UnknownObject: return f"UnknownObject({self.value})" -# --- Tests for primitive encoding --- +# --- Tests for primitive encoding (pass-through) --- def test_encode_string() -> None: """Test encoding a string value.""" - result = encode_checkpoint_value("hello") - assert result == "hello" + assert encode_checkpoint_value("hello") == "hello" def test_encode_integer() -> None: """Test encoding an integer value.""" - result = encode_checkpoint_value(42) - assert result == 42 + assert encode_checkpoint_value(42) == 42 def test_encode_float() -> None: """Test encoding a float value.""" - result = encode_checkpoint_value(3.14) - assert result == 3.14 + assert encode_checkpoint_value(3.14) == 3.14 def test_encode_boolean_true() -> None: """Test encoding a True boolean value.""" - result = encode_checkpoint_value(True) - assert result is True + assert encode_checkpoint_value(True) is True def test_encode_boolean_false() -> None: """Test encoding a False boolean value.""" - result = encode_checkpoint_value(False) - assert result is False + assert encode_checkpoint_value(False) is False def test_encode_none() -> None: """Test encoding a None value.""" - result = encode_checkpoint_value(None) - assert result is None + assert encode_checkpoint_value(None) is None # --- Tests for collection encoding --- @@ -112,8 +90,7 @@ def test_encode_none() -> None: def test_encode_empty_dict() -> None: """Test encoding an empty dictionary.""" - result = encode_checkpoint_value({}) - assert result == {} + assert encode_checkpoint_value({}) == {} def test_encode_simple_dict() -> None: @@ -132,8 +109,7 @@ def test_encode_dict_with_non_string_keys() -> None: def test_encode_empty_list() -> None: """Test encoding an empty list.""" - result = encode_checkpoint_value([]) - assert result == [] + assert encode_checkpoint_value([]) == [] def test_encode_simple_list() -> None: @@ -144,29 +120,26 @@ def test_encode_simple_list() -> None: def test_encode_tuple() -> None: - """Test encoding a tuple (converted to list).""" + """Test encoding a tuple (pickled to preserve type).""" data = (1, 2, 3) result = encode_checkpoint_value(data) - assert result == [1, 2, 3] + assert isinstance(result, dict) + assert _PICKLE_MARKER in result + assert _TYPE_MARKER in result def test_encode_set() -> None: - """Test encoding a set (converted to list).""" + """Test encoding a set (pickled to preserve type).""" data = {1, 2, 3} result = encode_checkpoint_value(data) - assert isinstance(result, list) - assert sorted(result) == [1, 2, 3] + assert isinstance(result, dict) + assert _PICKLE_MARKER in result + assert _TYPE_MARKER in result def test_encode_nested_dict() -> None: """Test encoding a nested dictionary structure.""" - data = { - "outer": { - "inner": { - "value": 42, - } - } - } + data = {"outer": {"inner": {"value": 42}}} result = encode_checkpoint_value(data) assert result == {"outer": {"inner": {"value": 42}}} @@ -178,18 +151,18 @@ def test_encode_list_of_dicts() -> None: assert result == [{"a": 1}, {"b": 2}] -# --- Tests for dataclass encoding --- +# --- Tests for non-JSON-native types (pickled) --- def test_encode_simple_dataclass() -> None: - """Test encoding a simple dataclass.""" + """Test encoding a simple dataclass produces a pickled entry.""" obj = SimpleDataclass(name="test", value=42) result = encode_checkpoint_value(obj) assert isinstance(result, dict) - assert DATACLASS_MARKER in result - assert "value" in result - assert result["value"] == {"name": "test", "value": 42} + assert _PICKLE_MARKER in result + assert _TYPE_MARKER in result + assert isinstance(result[_PICKLE_MARKER], str) # base64 string def test_encode_nested_dataclass() -> None: @@ -199,12 +172,8 @@ def test_encode_nested_dataclass() -> None: result = encode_checkpoint_value(outer) assert isinstance(result, dict) - assert DATACLASS_MARKER in result - assert "value" in result - - outer_value = result["value"] - assert outer_value["outer_name"] == "outer" - assert DATACLASS_MARKER in outer_value["inner"] + assert _PICKLE_MARKER in result + assert _TYPE_MARKER in result def test_encode_list_of_dataclasses() -> None: @@ -218,7 +187,7 @@ def test_encode_list_of_dataclasses() -> None: assert isinstance(result, list) assert len(result) == 2 for item in result: - assert DATACLASS_MARKER in item + assert _PICKLE_MARKER in item def test_encode_dict_with_dataclass_values() -> None: @@ -230,169 +199,77 @@ def test_encode_dict_with_dataclass_values() -> None: result = encode_checkpoint_value(data) assert isinstance(result, dict) - assert DATACLASS_MARKER in result["item1"] - assert DATACLASS_MARKER in result["item2"] - - -# --- Tests for model protocol encoding --- + assert _PICKLE_MARKER in result["item1"] + assert _PICKLE_MARKER in result["item2"] def test_encode_model_with_to_dict() -> None: - """Test encoding an object implementing to_dict/from_dict protocol.""" + """Test encoding an object with to_dict is pickled (not using to_dict).""" obj = ModelWithToDict(data="test_data") result = encode_checkpoint_value(obj) assert isinstance(result, dict) - assert MODEL_MARKER in result - assert result["strategy"] == "to_dict" - assert result["value"] == {"data": "test_data"} + assert _PICKLE_MARKER in result -def test_encode_model_with_to_json() -> None: - """Test encoding an object implementing to_json/from_json protocol.""" - obj = ModelWithToJson(data="test_data") - result = encode_checkpoint_value(obj) - - assert isinstance(result, dict) - assert MODEL_MARKER in result - assert result["strategy"] == "to_json" - assert '"data": "test_data"' in result["value"] - - -# --- Tests for unknown object encoding --- - - -def test_encode_unknown_object_fallback_to_string() -> None: - """Test that unknown objects are encoded as strings.""" +def test_encode_unknown_object() -> None: + """Test that arbitrary objects are pickled.""" obj = UnknownObject(value="test") result = encode_checkpoint_value(obj) - assert isinstance(result, str) - assert "UnknownObject" in result + assert isinstance(result, dict) + assert _PICKLE_MARKER in result -# --- Tests for cycle detection --- +def test_encode_datetime() -> None: + """Test that datetime objects are pickled.""" + dt = datetime(2024, 5, 4, 12, 30, 45, tzinfo=timezone.utc) + result = encode_checkpoint_value(dt) + + assert isinstance(result, dict) + assert _PICKLE_MARKER in result -def test_encode_dict_with_self_reference() -> None: - """Test that dict self-references are detected and handled.""" - data: dict[str, Any] = {"name": "test"} - data["self"] = data # Create circular reference - - result = encode_checkpoint_value(data) - assert result["name"] == "test" - assert result["self"] == _CYCLE_SENTINEL +# --- Tests for type marker --- -def test_encode_list_with_self_reference() -> None: - """Test that list self-references are detected and handled.""" - data: list[Any] = [1, 2] - data.append(data) # Create circular reference +def test_encode_type_marker_records_type_info() -> None: + """Test that encoded objects include correct type information.""" + obj = SimpleDataclass(name="test", value=42) + result = encode_checkpoint_value(obj) - result = encode_checkpoint_value(data) - assert result[0] == 1 - assert result[1] == 2 - assert result[2] == _CYCLE_SENTINEL + type_key = result[_TYPE_MARKER] + assert "SimpleDataclass" in type_key -# --- Tests for reserved keyword handling --- -# Note: Security is enforced at deserialization time by validating class types, -# not at serialization time. This allows legitimate encoded data to be re-encoded. +def test_encode_type_marker_uses_module_qualname_format() -> None: + """Test that type marker uses module:qualname format.""" + obj = SimpleDataclass(name="test", value=42) + result = encode_checkpoint_value(obj) + + type_key = result[_TYPE_MARKER] + assert ":" in type_key + module, qualname = type_key.split(":") + assert module # non-empty module + assert qualname == "SimpleDataclass" -def test_encode_allows_dict_with_model_marker_and_value() -> None: - """Test that encoding a dict with MODEL_MARKER and 'value' is allowed. +# --- Tests for JSON serializability --- - Security is enforced at deserialization time, not serialization time. - """ + +def test_encode_result_is_json_serializable() -> None: + """Test that encoded output is fully JSON-serializable.""" data = { - MODEL_MARKER: "some.module:SomeClass", - "value": {"data": "test"}, + "dc": SimpleDataclass(name="test", value=42), + "model": ModelWithToDict(data="test"), + "dt": datetime.now(timezone.utc), + "nested": [SimpleDataclass(name="n", value=1)], } - result = encode_checkpoint_value(data) - assert MODEL_MARKER in result - assert "value" in result - - -def test_encode_allows_dict_with_dataclass_marker_and_value() -> None: - """Test that encoding a dict with DATACLASS_MARKER and 'value' is allowed. - - Security is enforced at deserialization time, not serialization time. - """ - data = { - DATACLASS_MARKER: "some.module:SomeClass", - "value": {"field": "test"}, - } - result = encode_checkpoint_value(data) - assert DATACLASS_MARKER in result - assert "value" in result - - -def test_encode_allows_nested_dict_with_marker_keys() -> None: - """Test that encoding nested dict with marker keys is allowed. - - Security is enforced at deserialization time, not serialization time. - """ - nested_data = { - "outer": { - MODEL_MARKER: "some.module:SomeClass", - "value": {"data": "test"}, - } - } - result = encode_checkpoint_value(nested_data) - assert "outer" in result - assert MODEL_MARKER in result["outer"] - - -def test_encode_allows_marker_without_value() -> None: - """Test that a dict with marker key but without 'value' key is allowed.""" - data = { - MODEL_MARKER: "some.module:SomeClass", - "other_key": "allowed", - } - result = encode_checkpoint_value(data) - assert MODEL_MARKER in result - assert result["other_key"] == "allowed" - - -def test_encode_allows_value_without_marker() -> None: - """Test that a dict with 'value' key but without marker is allowed.""" - data = { - "value": {"nested": "data"}, - "other_key": "allowed", - } - result = encode_checkpoint_value(data) - assert "value" in result - assert result["other_key"] == "allowed" - - -# --- Tests for max depth protection --- - - -def test_encode_deep_nesting_triggers_max_depth() -> None: - """Test that very deep nesting triggers max depth protection.""" - # Create a deeply nested structure (over 100 levels) - data: dict[str, Any] = {"level": 0} - current = data - for i in range(105): - current["nested"] = {"level": i + 1} - current = current["nested"] result = encode_checkpoint_value(data) - - # Navigate to find the max_depth sentinel - current_result = result - found_max_depth = False - for _ in range(110): - if isinstance(current_result, dict) and "nested" in current_result: - current_result = current_result["nested"] - if current_result == "": - found_max_depth = True - break - else: - break - - assert found_max_depth, "Expected sentinel to be found in deeply nested structure" + # Should not raise + json_str = json.dumps(result) + assert isinstance(json_str, str) # --- Tests for mixed complex structures --- @@ -413,6 +290,7 @@ def test_encode_complex_mixed_structure() -> None: result = encode_checkpoint_value(data) + # Primitives and collections pass through assert result["string_value"] == "hello" assert result["int_value"] == 42 assert result["float_value"] == 3.14 @@ -420,4 +298,17 @@ def test_encode_complex_mixed_structure() -> None: assert result["none_value"] is None assert result["list_value"] == [1, 2, 3] assert result["nested_dict"] == {"a": 1, "b": 2} - assert DATACLASS_MARKER in result["dataclass_value"] + # Dataclass is pickled + assert _PICKLE_MARKER in result["dataclass_value"] + + +def test_encode_preserves_dict_with_pickle_marker_key() -> None: + """Test that regular dicts containing _PICKLE_MARKER key are recursively encoded.""" + data = { + _PICKLE_MARKER: "some_value", + "other_key": "test", + } + result = encode_checkpoint_value(data) + assert _PICKLE_MARKER in result + assert result[_PICKLE_MARKER] == "some_value" + assert result["other_key"] == "test" diff --git a/python/packages/core/tests/workflow/test_checkpoint_validation.py b/python/packages/core/tests/workflow/test_checkpoint_validation.py index 17175451ce..a9c748a324 100644 --- a/python/packages/core/tests/workflow/test_checkpoint_validation.py +++ b/python/packages/core/tests/workflow/test_checkpoint_validation.py @@ -44,7 +44,7 @@ async def test_resume_fails_when_graph_mismatch() -> None: # Run once to create checkpoints _ = [event async for event in workflow.run("hello", stream=True)] # noqa: F841 - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) assert checkpoints, "expected at least one checkpoint to be created" target_checkpoint = checkpoints[-1] @@ -67,7 +67,7 @@ async def test_resume_succeeds_when_graph_matches() -> None: workflow = build_workflow(storage, finish_id="finish") _ = [event async for event in workflow.run("hello", stream=True)] # noqa: F841 - checkpoints = sorted(await storage.list_checkpoints(), key=lambda c: c.timestamp) + checkpoints = sorted(await storage.list_checkpoints(workflow_name=workflow.name), key=lambda c: c.timestamp) target_checkpoint = checkpoints[0] resumed_workflow = build_workflow(storage, finish_id="finish") @@ -126,7 +126,7 @@ async def test_resume_succeeds_when_sub_workflow_matches() -> None: _ = [event async for event in workflow.run("hello", stream=True)] - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) assert checkpoints, "expected at least one checkpoint to be created" target_checkpoint = checkpoints[-1] @@ -150,7 +150,7 @@ async def test_resume_fails_when_sub_workflow_changes() -> None: _ = [event async for event in workflow.run("hello", stream=True)] - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) assert checkpoints, "expected at least one checkpoint to be created" target_checkpoint = checkpoints[-1] diff --git a/python/packages/core/tests/workflow/test_request_info_and_response.py b/python/packages/core/tests/workflow/test_request_info_and_response.py index b62bfafb7c..05a7ed1ec5 100644 --- a/python/packages/core/tests/workflow/test_request_info_and_response.py +++ b/python/packages/core/tests/workflow/test_request_info_and_response.py @@ -3,7 +3,6 @@ from dataclasses import dataclass from agent_framework import ( - FileCheckpointStorage, WorkflowBuilder, WorkflowContext, WorkflowEvent, @@ -323,90 +322,3 @@ class TestRequestInfoAndResponse: assert completed # Should not have any calculations performed due to invalid input assert len(executor.calculations_performed) == 0 - - async def test_checkpoint_with_pending_request_info_events(self): - """Test that request info events are properly serialized in checkpoints and can be restored.""" - import tempfile - - with tempfile.TemporaryDirectory() as temp_dir: - # Use file-based storage to test full serialization - storage = FileCheckpointStorage(temp_dir) - - # Create workflow with checkpointing enabled - executor = ApprovalRequiredExecutor(id="approval_executor") - workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build() - - # Step 1: Run workflow to completion to ensure checkpoints are created - request_info_event: WorkflowEvent | None = None - async for event in workflow.run("checkpoint test operation", stream=True): - if event.type == "request_info": - request_info_event = event - - # Verify request was emitted - assert request_info_event is not None - assert isinstance(request_info_event.data, UserApprovalRequest) - assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation" - assert request_info_event.source_executor_id == "approval_executor" - - # Step 2: List checkpoints to find the one with our pending request - checkpoints = await storage.list_checkpoints() - assert len(checkpoints) > 0, "No checkpoints were created during workflow execution" - - # Find the checkpoint with our pending request - checkpoint_with_request = None - for checkpoint in checkpoints: - if request_info_event.request_id in checkpoint.pending_request_info_events: - checkpoint_with_request = checkpoint - break - - assert checkpoint_with_request is not None, "No checkpoint found with pending request info event" - - # Step 3: Verify the pending request info event was properly serialized - serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id] - assert "data" in serialized_event - assert "request_id" in serialized_event - assert "source_executor_id" in serialized_event - assert "request_type" in serialized_event - assert serialized_event["request_id"] == request_info_event.request_id - assert serialized_event["source_executor_id"] == "approval_executor" - - # Step 4: Create a fresh workflow and restore from checkpoint - new_executor = ApprovalRequiredExecutor(id="approval_executor") - restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build() - - # Step 5: Resume from checkpoint and verify the request can be continued - completed = False - restored_request_event: WorkflowEvent | None = None - async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True): - # Should re-emit the pending request info event - if event.type == "request_info" and event.request_id == request_info_event.request_id: - restored_request_event = event - elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS: - completed = True - - assert completed, "Workflow should reach idle with pending requests state after restoration" - assert restored_request_event is not None, "Restored request info event should be emitted" - - # Verify the restored event matches the original - assert restored_request_event.source_executor_id == request_info_event.source_executor_id - assert isinstance(restored_request_event.data, UserApprovalRequest) - assert restored_request_event.data.prompt == request_info_event.data.prompt - assert restored_request_event.data.context == request_info_event.data.context - - # Step 6: Provide response to the restored request and complete the workflow - final_completed = False - async for event in restored_workflow.run( - stream=True, - responses={ - request_info_event.request_id: True # Approve the request - }, - ): - if event.type == "status" and event.state == WorkflowRunState.IDLE: - final_completed = True - - assert final_completed, "Workflow should complete after providing response to restored request" - - # Step 7: Verify the executor state was properly restored and response was processed - assert new_executor.approval_received is True - expected_result = "Operation approved: Please approve the operation: checkpoint test operation" - assert new_executor.final_result == expected_result diff --git a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py index 73b4b938c1..dbb01d6e66 100644 --- a/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py +++ b/python/packages/core/tests/workflow/test_request_info_event_rehydrate.py @@ -4,14 +4,27 @@ import json from dataclasses import dataclass, field from datetime import datetime, timezone -import pytest - -from agent_framework import InMemoryCheckpointStorage, InProcRunnerContext -from agent_framework._workflows._checkpoint_encoding import DATACLASS_MARKER, encode_checkpoint_value -from agent_framework._workflows._checkpoint_summary import get_checkpoint_summary +from agent_framework import ( + FileCheckpointStorage, + InMemoryCheckpointStorage, + InProcRunnerContext, + WorkflowBuilder, + WorkflowRunState, +) +from agent_framework._workflows._checkpoint_encoding import ( + _PICKLE_MARKER, + encode_checkpoint_value, +) from agent_framework._workflows._events import WorkflowEvent from agent_framework._workflows._state import State +from .test_request_info_and_response import ( + ApprovalRequiredExecutor, + CalculationRequest, + MultiRequestExecutor, + UserApprovalRequest, +) + @dataclass class MockRequest: ... @@ -46,13 +59,13 @@ async def test_rehydrate_request_info_event() -> None: runner_context = InProcRunnerContext(InMemoryCheckpointStorage()) await runner_context.add_request_info_event(request_info_event) - checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1) + checkpoint_id = await runner_context.create_checkpoint("test_name", "test_hash", State(), None, iteration_count=1) checkpoint = await runner_context.load_checkpoint(checkpoint_id) assert checkpoint is not None assert checkpoint.pending_request_info_events assert "request-123" in checkpoint.pending_request_info_events - assert "request_type" in checkpoint.pending_request_info_events["request-123"] + assert checkpoint.pending_request_info_events["request-123"].request_type is MockRequest # Rehydrate the context await runner_context.apply_checkpoint(checkpoint) @@ -67,97 +80,6 @@ async def test_rehydrate_request_info_event() -> None: assert isinstance(rehydrated_event.data, MockRequest) -async def test_rehydrate_fails_when_request_type_missing() -> None: - """Rehydration should fail is the request type is missing or fails to import.""" - request_info_event = WorkflowEvent.request_info( - request_id="request-123", - source_executor_id="review_gateway", - request_data=MockRequest(), - response_type=bool, - ) - - runner_context = InProcRunnerContext(InMemoryCheckpointStorage()) - await runner_context.add_request_info_event(request_info_event) - - checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1) - checkpoint = await runner_context.load_checkpoint(checkpoint_id) - - assert checkpoint is not None - assert checkpoint.pending_request_info_events - assert "request-123" in checkpoint.pending_request_info_events - assert "request_type" in checkpoint.pending_request_info_events["request-123"] - - # Modify the checkpoint to simulate missing request type - checkpoint.pending_request_info_events["request-123"]["request_type"] = "nonexistent.module:MissingRequest" - - # Rehydrate the context - with pytest.raises(ImportError): - await runner_context.apply_checkpoint(checkpoint) - - -async def test_rehydrate_fails_when_request_type_mismatch() -> None: - """Rehydration should fail if the request type is mismatched.""" - request_info_event = WorkflowEvent.request_info( - request_id="request-123", - source_executor_id="review_gateway", - request_data=MockRequest(), - response_type=bool, - ) - - runner_context = InProcRunnerContext(InMemoryCheckpointStorage()) - await runner_context.add_request_info_event(request_info_event) - - checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1) - checkpoint = await runner_context.load_checkpoint(checkpoint_id) - - assert checkpoint is not None - assert checkpoint.pending_request_info_events - assert "request-123" in checkpoint.pending_request_info_events - assert "request_type" in checkpoint.pending_request_info_events["request-123"] - - # Modify the checkpoint to simulate mismatched request type in the serialized data - checkpoint.pending_request_info_events["request-123"]["data"][DATACLASS_MARKER] = ( - "nonexistent.module:MissingRequest" - ) - - # Rehydrate the context - with pytest.raises(TypeError): - await runner_context.apply_checkpoint(checkpoint) - - -async def test_pending_requests_in_summary() -> None: - """Test that pending requests are correctly summarized in the checkpoint summary.""" - request_info_event = WorkflowEvent.request_info( - request_id="request-123", - source_executor_id="review_gateway", - request_data=MockRequest(), - response_type=bool, - ) - - runner_context = InProcRunnerContext(InMemoryCheckpointStorage()) - await runner_context.add_request_info_event(request_info_event) - - checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1) - checkpoint = await runner_context.load_checkpoint(checkpoint_id) - - assert checkpoint is not None - summary = get_checkpoint_summary(checkpoint) - - assert summary.checkpoint_id == checkpoint_id - assert summary.status == "awaiting request response" - - assert len(summary.pending_request_info_events) == 1 - pending_event = summary.pending_request_info_events[0] - assert isinstance(pending_event, WorkflowEvent) - assert pending_event.type == "request_info" - assert pending_event.request_id == "request-123" - - assert pending_event.source_executor_id == "review_gateway" - assert pending_event.request_type is MockRequest - assert pending_event.response_type is bool - assert isinstance(pending_event.data, MockRequest) - - async def test_request_info_event_serializes_non_json_payloads() -> None: req_1 = WorkflowEvent.request_info( request_id="req-1", @@ -176,20 +98,260 @@ async def test_request_info_event_serializes_non_json_payloads() -> None: await runner_context.add_request_info_event(req_1) await runner_context.add_request_info_event(req_2) - checkpoint_id = await runner_context.create_checkpoint(State(), iteration_count=1) + checkpoint_id = await runner_context.create_checkpoint("test_name", "test_hash", State(), None, iteration_count=1) checkpoint = await runner_context.load_checkpoint(checkpoint_id) # Should be JSON serializable despite datetime/slots serialized = json.dumps(encode_checkpoint_value(checkpoint)) + assert isinstance(serialized, str) + + # Verify the structure contains pickled data for the request data fields deserialized = json.loads(serialized) + assert _PICKLE_MARKER in deserialized # checkpoint itself is pickled - assert "value" in deserialized - deserialized = deserialized["value"] + # Verify we can rehydrate the checkpoint correctly + await runner_context.apply_checkpoint(checkpoint) + pending = await runner_context.get_pending_request_info_events() - assert "pending_request_info_events" in deserialized - pending_request_info_events = deserialized["pending_request_info_events"] - assert "req-1" in pending_request_info_events - assert isinstance(pending_request_info_events["req-1"]["data"]["value"]["issued_at"], str) + assert "req-1" in pending + rehydrated_1 = pending["req-1"] + assert isinstance(rehydrated_1.data, TimedApproval) + assert rehydrated_1.data.issued_at == datetime(2024, 5, 4, 12, 30, 45) - assert "req-2" in pending_request_info_events - assert pending_request_info_events["req-2"]["data"]["value"]["note"] == "slot-based" + assert "req-2" in pending + rehydrated_2 = pending["req-2"] + assert isinstance(rehydrated_2.data, SlottedApproval) + assert rehydrated_2.data.note == "slot-based" + + +async def test_checkpoint_with_pending_request_info_events(): + """Test that request info events are properly serialized in checkpoints and can be restored.""" + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + # Use file-based storage to test full serialization + storage = FileCheckpointStorage(temp_dir) + + # Create workflow with checkpointing enabled + executor = ApprovalRequiredExecutor(id="approval_executor") + workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build() + + # Step 1: Run workflow to completion to ensure checkpoints are created + request_info_event: WorkflowEvent | None = None + async for event in workflow.run("checkpoint test operation", stream=True): + if event.type == "request_info": + request_info_event = event + + # Verify request was emitted + assert request_info_event is not None + assert isinstance(request_info_event.data, UserApprovalRequest) + assert request_info_event.data.prompt == "Please approve the operation: checkpoint test operation" + assert request_info_event.source_executor_id == "approval_executor" + + # Step 2: List checkpoints to find the one with our pending request + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + assert len(checkpoints) > 0, "No checkpoints were created during workflow execution" + + # Find the checkpoint with our pending request + checkpoint_with_request = None + for checkpoint in checkpoints: + if request_info_event.request_id in checkpoint.pending_request_info_events: + checkpoint_with_request = checkpoint + break + + assert checkpoint_with_request is not None, "No checkpoint found with pending request info event" + + # Step 3: Verify the pending request info event was properly serialized + serialized_event = checkpoint_with_request.pending_request_info_events[request_info_event.request_id] + assert serialized_event.data + assert serialized_event.request_type is UserApprovalRequest + assert serialized_event.request_id == request_info_event.request_id + assert serialized_event.source_executor_id == "approval_executor" + + # Step 4: Create a fresh workflow and restore from checkpoint + new_executor = ApprovalRequiredExecutor(id="approval_executor") + restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build() + + # Step 5: Resume from checkpoint and verify the request can be continued + completed = False + restored_request_event: WorkflowEvent | None = None + async for event in restored_workflow.run(checkpoint_id=checkpoint_with_request.checkpoint_id, stream=True): + # Should re-emit the pending request info event + if event.type == "request_info" and event.request_id == request_info_event.request_id: + restored_request_event = event + elif event.type == "status" and event.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS: + completed = True + + assert completed, "Workflow should reach idle with pending requests state after restoration" + assert restored_request_event is not None, "Restored request info event should be emitted" + + # Verify the restored event matches the original + assert restored_request_event.source_executor_id == request_info_event.source_executor_id + assert isinstance(restored_request_event.data, UserApprovalRequest) + assert restored_request_event.data.prompt == request_info_event.data.prompt + assert restored_request_event.data.context == request_info_event.data.context + + # Step 6: Provide response to the restored request and complete the workflow + final_completed = False + async for event in restored_workflow.run( + stream=True, + responses={ + request_info_event.request_id: True # Approve the request + }, + ): + if event.type == "status" and event.state == WorkflowRunState.IDLE: + final_completed = True + + assert final_completed, "Workflow should complete after providing response to restored request" + + # Step 7: Verify the executor state was properly restored and response was processed + assert new_executor.approval_received is True + expected_result = "Operation approved: Please approve the operation: checkpoint test operation" + assert new_executor.final_result == expected_result + + +async def test_checkpoint_restore_with_responses_does_not_reemit_handled_requests(): + """Test that request_info events are not re-emitted when responses are provided with checkpoint restore. + + When calling run(checkpoint_id=..., responses=...), the workflow restores from a checkpoint + that contains pending request_info events. Because responses are provided for those events, + they should NOT be re-emitted in the event stream - they are considered "handled". + + Note: The workflow's internal state tracking still sees the request_info events (before filtering), + so the final status may be IDLE_WITH_PENDING_REQUESTS even though the requests were handled. + The key behavior we're testing is that the CALLER doesn't see the request_info events. + """ + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + # Use file-based storage to test full serialization + storage = FileCheckpointStorage(temp_dir) + + # Create workflow with checkpointing enabled + executor = ApprovalRequiredExecutor(id="approval_executor") + workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build() + + # Step 1: Run workflow until it emits a request_info event + request_info_event: WorkflowEvent | None = None + async for event in workflow.run("test pending request suppression", stream=True): + if event.type == "request_info": + request_info_event = event + + assert request_info_event is not None + request_id = request_info_event.request_id + + # Step 2: Find the checkpoint with the pending request + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + checkpoint_with_request = None + for checkpoint in checkpoints: + if request_id in checkpoint.pending_request_info_events: + checkpoint_with_request = checkpoint + break + + assert checkpoint_with_request is not None + + # Step 3: Create a fresh workflow and restore from checkpoint WITH responses in one call + new_executor = ApprovalRequiredExecutor(id="approval_executor") + restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build() + + # Track all emitted events + emitted_events: list[WorkflowEvent] = [] + async for event in restored_workflow.run( + checkpoint_id=checkpoint_with_request.checkpoint_id, + responses={request_id: True}, # Provide response for the pending request + stream=True, + ): + emitted_events.append(event) + + # Step 4: Verify the request_info event was NOT re-emitted to the caller + reemitted_request_info_events = [ + e for e in emitted_events if e.type == "request_info" and e.request_id == request_id + ] + assert len(reemitted_request_info_events) == 0, ( + f"request_info event should NOT be re-emitted when response is provided. " + f"Found {len(reemitted_request_info_events)} request_info events with request_id={request_id}" + ) + + # Step 5: Verify the response was processed by checking executor state + assert new_executor.approval_received is True, "Response should have been processed by the executor" + assert new_executor.final_result == ( + "Operation approved: Please approve the operation: test pending request suppression" + ) + + +async def test_checkpoint_restore_with_partial_responses_reemits_unhandled_requests(): + """Test that only unhandled request_info events are re-emitted when partial responses are provided. + + When calling run(checkpoint_id=..., responses=...) with responses for only some of the + pending requests, only the unhandled request_info events should be re-emitted. + """ + import tempfile + + with tempfile.TemporaryDirectory() as temp_dir: + storage = FileCheckpointStorage(temp_dir) + + # Create workflow with multiple requests + executor = MultiRequestExecutor(id="multi_executor") + workflow = WorkflowBuilder(start_executor=executor, checkpoint_storage=storage).build() + + # Step 1: Run workflow until it emits multiple request_info events + request_events: list[WorkflowEvent] = [] + async for event in workflow.run("start batch", stream=True): + if event.type == "request_info": + request_events.append(event) + + assert len(request_events) == 2 + + # Find the approval and calculation requests + approval_event = next((e for e in request_events if isinstance(e.data, UserApprovalRequest)), None) + calc_event = next((e for e in request_events if isinstance(e.data, CalculationRequest)), None) + assert approval_event is not None + assert calc_event is not None + + # Step 2: Find the checkpoint with pending requests + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) + checkpoint_with_requests = None + for checkpoint in checkpoints: + has_approval = approval_event.request_id in checkpoint.pending_request_info_events + has_calc = calc_event.request_id in checkpoint.pending_request_info_events + if has_approval and has_calc: + checkpoint_with_requests = checkpoint + break + + assert checkpoint_with_requests is not None + + # Step 3: Restore from checkpoint with ONLY the approval response (not the calculation) + new_executor = MultiRequestExecutor(id="multi_executor") + restored_workflow = WorkflowBuilder(start_executor=new_executor, checkpoint_storage=storage).build() + + emitted_events: list[WorkflowEvent] = [] + async for event in restored_workflow.run( + checkpoint_id=checkpoint_with_requests.checkpoint_id, + responses={approval_event.request_id: True}, # Only respond to approval + stream=True, + ): + emitted_events.append(event) + + # Step 4: Verify the approval request_info was NOT re-emitted + reemitted_approval_events = [ + e for e in emitted_events if e.type == "request_info" and e.request_id == approval_event.request_id + ] + assert len(reemitted_approval_events) == 0, ( + "Approval request_info should NOT be re-emitted since response was provided" + ) + + # Step 5: Verify the calculation request_info WAS re-emitted (no response provided) + reemitted_calc_events = [ + e for e in emitted_events if e.type == "request_info" and e.request_id == calc_event.request_id + ] + assert len(reemitted_calc_events) == 1, ( + "Calculation request_info SHOULD be re-emitted since no response was provided" + ) + + # Step 6: Verify workflow is in IDLE_WITH_PENDING_REQUESTS state (calc still pending) + status_events = [e for e in emitted_events if e.type == "status"] + final_status = status_events[-1] if status_events else None + assert final_status is not None + assert final_status.state == WorkflowRunState.IDLE_WITH_PENDING_REQUESTS, ( + f"Workflow should be IDLE_WITH_PENDING_REQUESTS, got {final_status.state}" + ) diff --git a/python/packages/core/tests/workflow/test_runner.py b/python/packages/core/tests/workflow/test_runner.py index e527ba13fa..039c61b07d 100644 --- a/python/packages/core/tests/workflow/test_runner.py +++ b/python/packages/core/tests/workflow/test_runner.py @@ -2,6 +2,7 @@ import asyncio from dataclasses import dataclass +from unittest.mock import AsyncMock, MagicMock import pytest @@ -9,6 +10,9 @@ from agent_framework import ( AgentExecutorResponse, AgentResponse, Executor, + InMemoryCheckpointStorage, + WorkflowCheckpoint, + WorkflowCheckpointException, WorkflowContext, WorkflowConvergenceException, WorkflowEvent, @@ -16,6 +20,7 @@ from agent_framework import ( WorkflowRunState, handler, ) +from agent_framework._workflows._const import EXECUTOR_STATE_KEY from agent_framework._workflows._edge import SingleEdgeGroup from agent_framework._workflows._runner import Runner from agent_framework._workflows._runner_context import ( @@ -61,7 +66,14 @@ def test_create_runner(): executor_b.id: executor_b, } - runner = Runner(edge_groups, executors, state=State(), ctx=InProcRunnerContext()) + runner = Runner( + edge_groups, + executors, + state=State(), + ctx=InProcRunnerContext(), + workflow_name="test_name", + graph_signature_hash="test_hash", + ) assert runner.context is not None and isinstance(runner.context, RunnerContext) @@ -84,7 +96,7 @@ async def test_runner_run_until_convergence(): state = State() ctx = InProcRunnerContext() - runner = Runner(edges, executors, state, ctx) + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash") result: int | None = None await executor_a.execute( @@ -122,7 +134,7 @@ async def test_runner_run_until_convergence_not_completed(): state = State() ctx = InProcRunnerContext() - runner = Runner(edges, executors, state, ctx, max_iterations=5) + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash", max_iterations=5) await executor_a.execute( MockMessage(data=0), @@ -156,7 +168,7 @@ async def test_runner_already_running(): state = State() ctx = InProcRunnerContext() - runner = Runner(edges, executors, state, ctx) + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash") await executor_a.execute( MockMessage(data=0), @@ -176,7 +188,7 @@ async def test_runner_already_running(): async def test_runner_emits_runner_completion_for_agent_response_without_targets(): ctx = InProcRunnerContext() - runner = Runner([], {}, State(), ctx) + runner = Runner([], {}, State(), ctx, "test_name", graph_signature_hash="test_hash") await ctx.send_message( WorkflowMessage( @@ -228,7 +240,7 @@ async def test_runner_cancellation_stops_active_executor(): shared_state = State() ctx = InProcRunnerContext() - runner = Runner(edges, executors, shared_state, ctx) + runner = Runner(edges, executors, shared_state, ctx, "test_name", graph_signature_hash="test_hash") await executor_a.execute( MockMessage(data=0), @@ -259,3 +271,579 @@ async def test_runner_cancellation_stops_active_executor(): assert executor_a.completed_count == 1 assert executor_b.started_count == 1 assert executor_b.completed_count == 0 # Should NOT have completed due to cancellation + + +class FailingExecutor(Executor): + """An executor that fails during execution.""" + + def __init__(self, id: str, fail_on_data: int = 5): + super().__init__(id=id) + self.fail_on_data = fail_on_data + + @handler + async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None: + if message.data == self.fail_on_data: + raise RuntimeError("Simulated executor failure") + await ctx.send_message(MockMessage(data=message.data + 1)) + + +async def test_runner_iteration_exception_drains_events(): + """Test that when an executor raises an exception, events are drained before propagating.""" + executor_a = FailingExecutor(id="executor_a", fail_on_data=2) + executor_b = MockExecutor(id="executor_b") + + edges = [ + SingleEdgeGroup(executor_a.id, executor_b.id), + SingleEdgeGroup(executor_b.id, executor_a.id), + ] + + executors: dict[str, Executor] = { + executor_a.id: executor_a, + executor_b.id: executor_b, + } + state = State() + ctx = InProcRunnerContext() + + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash") + + await executor_a.execute( + MockMessage(data=0), + ["START"], + state, + ctx, + ) + + events: list[WorkflowEvent] = [] + with pytest.raises(RuntimeError, match="Simulated executor failure"): + async for event in runner.run_until_convergence(): + events.append(event) + + # There should be some events emitted before the failure + assert len(events) > 0 + + +async def test_runner_reset_iteration_count(): + """Test that reset_iteration_count works correctly.""" + executor_a = MockExecutor(id="executor_a") + state = State() + ctx = InProcRunnerContext() + + runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash") + runner._iteration = 10 + + runner.reset_iteration_count() + + assert runner._iteration == 0 + + +class CheckpointingContext(InProcRunnerContext): + """A context that supports checkpointing for testing.""" + + def __init__(self, storage: InMemoryCheckpointStorage | None = None): + super().__init__() + self._storage = storage or InMemoryCheckpointStorage() + self._checkpointing_enabled = True + + def has_checkpointing(self) -> bool: + return self._checkpointing_enabled + + async def create_checkpoint( + self, + workflow_name: str, + graph_signature_hash: str, + state: State, + previous_checkpoint_id: str | None, + iteration: int, + ) -> str: + checkpoint = WorkflowCheckpoint( + workflow_name=workflow_name, + graph_signature_hash=graph_signature_hash, + state=state.export(), + previous_checkpoint_id=previous_checkpoint_id, + iteration_count=iteration, + ) + return await self._storage.save(checkpoint) + + async def load_checkpoint(self, checkpoint_id: str) -> WorkflowCheckpoint | None: + try: + return await self._storage.load(checkpoint_id) + except WorkflowCheckpointException: + return None + + async def apply_checkpoint(self, checkpoint: WorkflowCheckpoint) -> None: + # Restore messages from checkpoint + for source_id, messages in checkpoint.messages.items(): + for msg_data in messages: + await self.send_message(WorkflowMessage(data=msg_data, source_id=source_id)) + + +class FailingCheckpointContext(InProcRunnerContext): + """A context that fails during checkpoint creation.""" + + def has_checkpointing(self) -> bool: + return True + + async def create_checkpoint( + self, + workflow_name: str, + graph_signature_hash: str, + state: State, + previous_checkpoint_id: str | None, + iteration: int, + ) -> str: + raise RuntimeError("Simulated checkpoint failure") + + +async def test_runner_checkpoint_creation_failure(): + """Test that checkpoint creation failure is handled gracefully.""" + executor_a = MockExecutor(id="executor_a") + executor_b = MockExecutor(id="executor_b") + + edges = [ + SingleEdgeGroup(executor_a.id, executor_b.id), + SingleEdgeGroup(executor_b.id, executor_a.id), + ] + + executors: dict[str, Executor] = { + executor_a.id: executor_a, + executor_b.id: executor_b, + } + state = State() + ctx = FailingCheckpointContext() + + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash") + + await executor_a.execute( + MockMessage(data=0), + ["START"], + state, + ctx, + ) + + # Should complete without raising, even though checkpointing fails + result: int | None = None + async for event in runner.run_until_convergence(): + if event.type == "output": + result = event.data + + assert result == 10 + + +async def test_runner_restore_from_checkpoint_with_external_storage(): + """Test restoring from checkpoint using external storage when context has no checkpointing.""" + executor_a = MockExecutor(id="executor_a") + executor_b = MockExecutor(id="executor_b") + + edges = [ + SingleEdgeGroup(executor_a.id, executor_b.id), + SingleEdgeGroup(executor_b.id, executor_a.id), + ] + + executors: dict[str, Executor] = { + executor_a.id: executor_a, + executor_b.id: executor_b, + } + state = State() + ctx = InProcRunnerContext() # No checkpointing enabled + + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash") + + # Create a checkpoint manually + storage = InMemoryCheckpointStorage() + checkpoint = WorkflowCheckpoint( + workflow_name="test_name", + graph_signature_hash="test_hash", + state={"test_key": "test_value"}, + iteration_count=5, + ) + checkpoint_id = await storage.save(checkpoint) + + # Restore using external storage + await runner.restore_from_checkpoint(checkpoint_id, checkpoint_storage=storage) + + assert runner._resumed_from_checkpoint is True + assert runner._iteration == 5 + assert state.get("test_key") == "test_value" + + +async def test_runner_restore_from_checkpoint_no_storage(): + """Test that restore fails when no checkpointing and no external storage.""" + state = State() + ctx = InProcRunnerContext() + + runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash") + + with pytest.raises(WorkflowCheckpointException, match="Cannot load checkpoint"): + await runner.restore_from_checkpoint("nonexistent-id") + + +async def test_runner_restore_from_checkpoint_not_found(): + """Test that restore fails when checkpoint is not found.""" + storage = InMemoryCheckpointStorage() + ctx = CheckpointingContext(storage) + state = State() + + runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash") + + with pytest.raises(WorkflowCheckpointException, match="not found"): + await runner.restore_from_checkpoint("nonexistent-id") + + +async def test_runner_restore_from_checkpoint_graph_hash_mismatch(): + """Test that restore fails when graph hash doesn't match.""" + storage = InMemoryCheckpointStorage() + ctx = CheckpointingContext(storage) + state = State() + + runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="current_hash") + + # Create a checkpoint with a different graph hash + checkpoint = WorkflowCheckpoint( + workflow_name="test_name", + graph_signature_hash="different_hash", + state={}, + iteration_count=5, + ) + checkpoint_id = await storage.save(checkpoint) + + with pytest.raises(WorkflowCheckpointException, match="Workflow graph has changed"): + await runner.restore_from_checkpoint(checkpoint_id) + + +async def test_runner_restore_from_checkpoint_generic_exception(): + """Test that generic exceptions during restore are wrapped in WorkflowCheckpointException.""" + state = State() + + # Create a mock context that raises a generic exception + mock_ctx = MagicMock(spec=InProcRunnerContext) + mock_ctx.has_checkpointing.return_value = True + mock_ctx.load_checkpoint = AsyncMock(side_effect=ValueError("Unexpected error")) + + runner = Runner([], {}, state, mock_ctx, "test_name", graph_signature_hash="test_hash") + + with pytest.raises(WorkflowCheckpointException, match="Failed to restore from checkpoint"): + await runner.restore_from_checkpoint("some-id") + + +async def test_runner_restore_executor_states_invalid_states_type(): + """Test that restore fails when executor states is not a dict.""" + executor_a = MockExecutor(id="executor_a") + state = State() + state.set(EXECUTOR_STATE_KEY, "not_a_dict") + state.commit() + + ctx = InProcRunnerContext() + runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash") + + with pytest.raises(WorkflowCheckpointException, match="not a dictionary"): + await runner._restore_executor_states() + + +async def test_runner_restore_executor_states_invalid_executor_id_type(): + """Test that restore fails when executor ID is not a string.""" + executor_a = MockExecutor(id="executor_a") + state = State() + state.set(EXECUTOR_STATE_KEY, {123: {"key": "value"}}) # Non-string key + state.commit() + + ctx = InProcRunnerContext() + runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash") + + with pytest.raises(WorkflowCheckpointException, match="not a string"): + await runner._restore_executor_states() + + +async def test_runner_restore_executor_states_invalid_state_type(): + """Test that restore fails when executor state is not a dict[str, Any].""" + executor_a = MockExecutor(id="executor_a") + state = State() + state.set(EXECUTOR_STATE_KEY, {"executor_a": "not_a_dict"}) + state.commit() + + ctx = InProcRunnerContext() + runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash") + + with pytest.raises(WorkflowCheckpointException, match="not a dict"): + await runner._restore_executor_states() + + +async def test_runner_restore_executor_states_invalid_state_keys(): + """Test that restore fails when executor state dict has non-string keys.""" + executor_a = MockExecutor(id="executor_a") + state = State() + state.set(EXECUTOR_STATE_KEY, {"executor_a": {123: "value"}}) # Non-string key in state + state.commit() + + ctx = InProcRunnerContext() + runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash") + + with pytest.raises(WorkflowCheckpointException, match="not a dict"): + await runner._restore_executor_states() + + +async def test_runner_restore_executor_states_missing_executor(): + """Test that restore fails when executor is not found.""" + state = State() + state.set(EXECUTOR_STATE_KEY, {"missing_executor": {"key": "value"}}) + state.commit() + + ctx = InProcRunnerContext() + runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash") + + with pytest.raises(WorkflowCheckpointException, match="not found during state restoration"): + await runner._restore_executor_states() + + +async def test_runner_set_executor_state_invalid_existing_states(): + """Test that _set_executor_state fails when existing states is not a dict.""" + executor_a = MockExecutor(id="executor_a") + state = State() + state.set(EXECUTOR_STATE_KEY, "not_a_dict") + + ctx = InProcRunnerContext() + runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash") + + with pytest.raises(WorkflowCheckpointException, match="not a dictionary"): + await runner._set_executor_state("executor_a", {"key": "value"}) + + +async def test_runner_with_pre_loop_events(): + """Test that pre-loop events are yielded correctly.""" + ctx = InProcRunnerContext() + state = State() + + runner = Runner([], {}, state, ctx, "test_name", graph_signature_hash="test_hash") + + # Add an event before running + await ctx.add_event(WorkflowEvent.output(executor_id="test_executor", data="pre-loop-output")) + + events: list[WorkflowEvent] = [] + async for event in runner.run_until_convergence(): + events.append(event) + + # Should have the pre-loop output event + output_events = [e for e in events if e.type == "output"] + assert len(output_events) == 1 + assert output_events[0].data == "pre-loop-output" + + +class EventEmittingExecutor(Executor): + """An executor that emits events during execution.""" + + @handler + async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None: + # Emit event during processing + await ctx.yield_output(f"processed-{message.data}") + if message.data < 3: + await ctx.send_message(MockMessage(data=message.data + 1)) + + +async def test_runner_drains_straggler_events(): + """Test that events emitted at the end of iteration are drained.""" + executor_a = EventEmittingExecutor(id="executor_a") + executor_b = EventEmittingExecutor(id="executor_b") + + edges = [ + SingleEdgeGroup(executor_a.id, executor_b.id), + SingleEdgeGroup(executor_b.id, executor_a.id), + ] + + executors: dict[str, Executor] = { + executor_a.id: executor_a, + executor_b.id: executor_b, + } + state = State() + ctx = InProcRunnerContext() + + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash") + + await executor_a.execute( + MockMessage(data=0), + ["START"], + state, + ctx, + ) + + events: list[WorkflowEvent] = [] + async for event in runner.run_until_convergence(): + events.append(event) + + # Should have output events from both executors + output_events = [e for e in events if e.type == "output"] + assert len(output_events) > 0 + + +async def test_runner_restore_executor_states_no_states(): + """Test that restore does nothing when there are no executor states.""" + executor_a = MockExecutor(id="executor_a") + state = State() # No executor states set + state.commit() + + ctx = InProcRunnerContext() + runner = Runner([], {executor_a.id: executor_a}, state, ctx, "test_name", graph_signature_hash="test_hash") + + # Should complete without error when no executor states exist + await runner._restore_executor_states() + + +async def test_runner_checkpoint_with_resumed_flag(): + """Test that resumed flag prevents initial checkpoint creation.""" + storage = InMemoryCheckpointStorage() + ctx = CheckpointingContext(storage) + executor_a = MockExecutor(id="executor_a") + executor_b = MockExecutor(id="executor_b") + + edges = [ + SingleEdgeGroup(executor_a.id, executor_b.id), + SingleEdgeGroup(executor_b.id, executor_a.id), + ] + + executors: dict[str, Executor] = { + executor_a.id: executor_a, + executor_b.id: executor_b, + } + state = State() + + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash") + runner._mark_resumed(5) + + # Add a message to trigger the checkpoint creation path + await ctx.send_message(WorkflowMessage(data=MockMessage(data=8), source_id="START")) + + await executor_a.execute( + MockMessage(data=8), + ["START"], + state, + ctx, + ) + + # Run until convergence + async for _ in runner.run_until_convergence(): + pass + + # After completing, resumed flag should be reset + assert runner._resumed_from_checkpoint is False + + +class ExecutorThatFailsWithEvents(Executor): + """An executor that emits events and then raises an exception after receiving messages.""" + + def __init__(self, id: str, runner_ctx: RunnerContext, fail_on_iteration: int = 1): + super().__init__(id=id) + self._runner_ctx = runner_ctx + self._fail_on_iteration = fail_on_iteration + self._iteration_count = 0 + + @handler + async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None: + self._iteration_count += 1 + # First emit an output event to the workflow context + await ctx.yield_output(f"output-before-failure-{message.data}") + # Add some events directly to the runner context + await self._runner_ctx.add_event(WorkflowEvent.output(executor_id=self.id, data="pending-event")) + # Fail on the specified iteration + if self._iteration_count >= self._fail_on_iteration: + raise RuntimeError("Executor failed with pending events") + # Otherwise, send to next + await ctx.send_message(MockMessage(data=message.data + 1)) + + +class PassthroughExecutor(Executor): + """An executor that passes messages through to the failing executor.""" + + @handler + async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None: + await ctx.send_message(MockMessage(data=message.data)) + + +async def test_runner_drains_events_on_iteration_exception(): + """Test that events are drained when iteration task raises an exception (lines 128-129).""" + ctx = InProcRunnerContext() + # executor_b will fail with pending events after receiving a message + executor_a = PassthroughExecutor(id="executor_a") + executor_b = ExecutorThatFailsWithEvents(id="executor_b", runner_ctx=ctx, fail_on_iteration=1) + + edges = [ + SingleEdgeGroup(executor_a.id, executor_b.id), + ] + + executors: dict[str, Executor] = { + executor_a.id: executor_a, + executor_b.id: executor_b, + } + state = State() + + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash") + + # Execute through executor_a which will pass to executor_b during the runner iteration + await executor_a.execute( + MockMessage(data=0), + ["START"], + state, + ctx, + ) + + events: list[WorkflowEvent] = [] + with pytest.raises(RuntimeError, match="Executor failed with pending events"): + async for event in runner.run_until_convergence(): + events.append(event) + + # Events should include the ones emitted before the exception + output_events = [e for e in events if e.type == "output"] + # Should have drained the pending events before propagating the exception + assert len(output_events) >= 1 + + +class SlowEventEmittingExecutor(Executor): + """An executor that emits events with delays to test straggler event draining.""" + + def __init__(self, id: str, iterations_to_emit: int = 2): + super().__init__(id=id) + self.iterations_to_emit = iterations_to_emit + self.current_iteration = 0 + + @handler + async def handle(self, message: MockMessage, ctx: WorkflowContext[MockMessage, int]) -> None: + self.current_iteration += 1 + # Emit output event + await ctx.yield_output(f"iteration-{self.current_iteration}") + # Continue sending messages until we reach the target iterations + if self.current_iteration < self.iterations_to_emit: + await ctx.send_message(MockMessage(data=message.data + 1)) + + +async def test_runner_drains_straggler_events_at_iteration_end(): + """Test that events emitted at the very end of iteration are drained (lines 135-136).""" + # Create executors that ping-pong messages and emit events + executor_a = SlowEventEmittingExecutor(id="executor_a", iterations_to_emit=3) + executor_b = SlowEventEmittingExecutor(id="executor_b", iterations_to_emit=3) + + edges = [ + SingleEdgeGroup(executor_a.id, executor_b.id), + SingleEdgeGroup(executor_b.id, executor_a.id), + ] + + executors: dict[str, Executor] = { + executor_a.id: executor_a, + executor_b.id: executor_b, + } + state = State() + ctx = InProcRunnerContext() + + runner = Runner(edges, executors, state, ctx, "test_name", graph_signature_hash="test_hash") + + await executor_a.execute( + MockMessage(data=0), + ["START"], + state, + ctx, + ) + + events: list[WorkflowEvent] = [] + async for event in runner.run_until_convergence(): + events.append(event) + + # Check that output events were collected (including straggler events) + output_events = [e for e in events if e.type == "output"] + # We should have output events from both executors + assert len(output_events) >= 2 diff --git a/python/packages/core/tests/workflow/test_serialization.py b/python/packages/core/tests/workflow/test_serialization.py index f579c1be76..55284db407 100644 --- a/python/packages/core/tests/workflow/test_serialization.py +++ b/python/packages/core/tests/workflow/test_serialization.py @@ -647,12 +647,11 @@ class TestSerializationWorkflowClasses: # Test 2: Without name and description (defaults) workflow2 = WorkflowBuilder(start_executor=SampleExecutor(id="e2")).build() - assert workflow2.name is None + assert workflow2.name is not None assert workflow2.description is None data2 = workflow2.to_dict() - assert "name" not in data2 # Should not include None values - assert "description" not in data2 + assert "description" not in data2 # Should not include None values # Test 3: With only name (no description) workflow3 = WorkflowBuilder(name="Named Only", start_executor=SampleExecutor(id="e3")).build() diff --git a/python/packages/core/tests/workflow/test_sub_workflow.py b/python/packages/core/tests/workflow/test_sub_workflow.py index 55afad880f..666e82f4d7 100644 --- a/python/packages/core/tests/workflow/test_sub_workflow.py +++ b/python/packages/core/tests/workflow/test_sub_workflow.py @@ -595,7 +595,7 @@ async def test_sub_workflow_checkpoint_restore_no_duplicate_requests() -> None: assert first_request_id is not None # Get checkpoint - checkpoints = await storage.list_checkpoints(workflow1.id) + checkpoints = await storage.list_checkpoints(workflow_name=workflow1.name) checkpoint_id = max(checkpoints, key=lambda cp: cp.iteration_count).checkpoint_id # Step 2: Resume workflow from checkpoint diff --git a/python/packages/core/tests/workflow/test_workflow.py b/python/packages/core/tests/workflow/test_workflow.py index 6728bcfcb1..744ad827ea 100644 --- a/python/packages/core/tests/workflow/test_workflow.py +++ b/python/packages/core/tests/workflow/test_workflow.py @@ -335,12 +335,9 @@ async def test_workflow_run_stream_from_checkpoint_invalid_checkpoint( ) # Attempt to run from non-existent checkpoint should fail - try: + with pytest.raises(WorkflowCheckpointException, match="No checkpoint found with ID nonexistent_checkpoint_id"): async for _ in workflow.run(checkpoint_id="nonexistent_checkpoint_id", stream=True): pass - raise AssertionError("Expected WorkflowCheckpointException to be raised") - except WorkflowCheckpointException as e: - assert str(e) == "Checkpoint nonexistent_checkpoint_id not found" async def test_workflow_run_stream_from_checkpoint_with_external_storage( @@ -354,12 +351,14 @@ async def test_workflow_run_stream_from_checkpoint_with_external_storage( from agent_framework import WorkflowCheckpoint test_checkpoint = WorkflowCheckpoint( - workflow_id="test-workflow", + workflow_name="test-workflow", + graph_signature_hash="test-graph-signature", + previous_checkpoint_id=None, messages={}, state={}, iteration_count=0, ) - checkpoint_id = await storage.save_checkpoint(test_checkpoint) + checkpoint_id = await storage.save(test_checkpoint) # Create a workflow WITHOUT checkpointing workflow_without_checkpointing = ( @@ -385,17 +384,6 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) - # Create a test checkpoint manually in storage - from agent_framework import WorkflowCheckpoint - - test_checkpoint = WorkflowCheckpoint( - workflow_id="test-workflow", - messages={}, - state={}, - iteration_count=0, - ) - checkpoint_id = await storage.save_checkpoint(test_checkpoint) - # Build workflow with checkpointing workflow = ( WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage) @@ -403,6 +391,19 @@ async def test_workflow_run_from_checkpoint_non_streaming(simple_executor: Execu .build() ) + # Create a test checkpoint manually in storage + from agent_framework import WorkflowCheckpoint + + test_checkpoint = WorkflowCheckpoint( + workflow_name=workflow.name, + graph_signature_hash=workflow.graph_signature_hash, + previous_checkpoint_id=None, + messages={}, + state={}, + iteration_count=0, + ) + checkpoint_id = await storage.save(test_checkpoint) + # Test non-streaming run method with checkpoint_id result = await workflow.run(checkpoint_id=checkpoint_id) assert isinstance(result, list) # Should return WorkflowRunResult which extends list @@ -416,11 +417,19 @@ async def test_workflow_run_stream_from_checkpoint_with_responses( with tempfile.TemporaryDirectory() as temp_dir: storage = FileCheckpointStorage(temp_dir) + # Build workflow with checkpointing + workflow = ( + WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage) + .add_edge(simple_executor, simple_executor) + .build() + ) + # Create a test checkpoint manually in storage from agent_framework import WorkflowCheckpoint test_checkpoint = WorkflowCheckpoint( - workflow_id="test-workflow", + workflow_name=workflow.name, + graph_signature_hash=workflow.graph_signature_hash, messages={}, state={}, pending_request_info_events={ @@ -429,18 +438,11 @@ async def test_workflow_run_stream_from_checkpoint_with_responses( source_executor_id=simple_executor.id, request_data="Mock", response_type=str, - ).to_dict(), + ), }, iteration_count=0, ) - checkpoint_id = await storage.save_checkpoint(test_checkpoint) - - # Build workflow with checkpointing - workflow = ( - WorkflowBuilder(start_executor=simple_executor, checkpoint_storage=storage) - .add_edge(simple_executor, simple_executor) - .build() - ) + checkpoint_id = await storage.save(test_checkpoint) # Resume from checkpoint - pending request events should be emitted events: list[WorkflowEvent] = [] @@ -542,7 +544,7 @@ async def test_workflow_checkpoint_runtime_only_configuration( assert result.get_final_state() == WorkflowRunState.IDLE # Verify checkpoints were created - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) assert len(checkpoints) > 0 # Find a superstep checkpoint to resume from @@ -592,8 +594,8 @@ async def test_workflow_checkpoint_runtime_overrides_buildtime( assert result is not None # Verify checkpoints were created in runtime storage, not build-time storage - buildtime_checkpoints = await buildtime_storage.list_checkpoints() - runtime_checkpoints = await runtime_storage.list_checkpoints() + buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=workflow.name) + runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=workflow.name) assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" diff --git a/python/packages/core/tests/workflow/test_workflow_agent.py b/python/packages/core/tests/workflow/test_workflow_agent.py index 1ccc400f92..2a1532502b 100644 --- a/python/packages/core/tests/workflow/test_workflow_agent.py +++ b/python/packages/core/tests/workflow/test_workflow_agent.py @@ -607,7 +607,7 @@ class TestWorkflowAgent: # Drain workflow events to get checkpoint # The workflow should have created checkpoints - checkpoints = await checkpoint_storage.list_checkpoints(workflow.id) + checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name) assert len(checkpoints) > 0, "Checkpoints should have been created when checkpoint_storage is provided" async def test_agent_executor_output_response_false_filters_streaming_events(self): diff --git a/python/packages/core/tests/workflow/test_workflow_observability.py b/python/packages/core/tests/workflow/test_workflow_observability.py index d5c20ad429..b2260abe63 100644 --- a/python/packages/core/tests/workflow/test_workflow_observability.py +++ b/python/packages/core/tests/workflow/test_workflow_observability.py @@ -306,8 +306,8 @@ async def test_end_to_end_workflow_tracing(span_exporter: InMemorySpanExporter) assert len(build_spans_with_metadata) == 1 metadata_build_span = build_spans_with_metadata[0] assert metadata_build_span.attributes is not None - assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_NAME) == "Test Pipeline" - assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_DESCRIPTION) == "Test workflow description" + assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_BUILDER_NAME) == "Test Pipeline" + assert metadata_build_span.attributes.get(OtelAttr.WORKFLOW_BUILDER_DESCRIPTION) == "Test workflow description" # Clear spans to separate build from run tracing span_exporter.clear() @@ -451,14 +451,14 @@ async def test_message_trace_context_serialization(span_exporter: InMemorySpanEx await ctx.send_message(message) # Create a checkpoint that includes the message - checkpoint_id = await ctx.create_checkpoint(State(), 0) + checkpoint_id = await ctx.create_checkpoint("test_name", "test_hash", State(), None, 0) checkpoint = await ctx.load_checkpoint(checkpoint_id) assert checkpoint is not None # Check serialized message includes trace context serialized_msg = checkpoint.messages["source"][0] - assert serialized_msg["trace_contexts"] == [{"traceparent": "00-trace-span-01"}] - assert serialized_msg["source_span_ids"] == ["span123"] + assert serialized_msg.trace_contexts == [{"traceparent": "00-trace-span-01"}] + assert serialized_msg.source_span_ids == ["span123"] # Test deserialization await ctx.apply_checkpoint(checkpoint) diff --git a/python/packages/declarative/agent_framework_declarative/_loader.py b/python/packages/declarative/agent_framework_declarative/_loader.py index f3bbb6d87a..001f6f511e 100644 --- a/python/packages/declarative/agent_framework_declarative/_loader.py +++ b/python/packages/declarative/agent_framework_declarative/_loader.py @@ -605,10 +605,11 @@ class AgentFactory: # Parse tools tools = self._parse_tools(prompt_agent.tools) if prompt_agent.tools else None - # Parse response format - response_format = None + # Parse response format into default_options + default_options: dict[str, Any] | None = None if prompt_agent.outputSchema: response_format = _create_model_from_json_schema("agent", prompt_agent.outputSchema.to_json_schema()) + default_options = {"response_format": response_format} # Create the agent using the provider # The provider's create_agent returns a Agent directly @@ -620,7 +621,7 @@ class AgentFactory: instructions=prompt_agent.instructions, description=prompt_agent.description, tools=tools, - response_format=response_format, + default_options=default_options, ), ) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py index b7c05b8607..e34f6e06f8 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_actions_agents.py @@ -327,6 +327,16 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl max_iterations = 100 # Safety limit # Start external loop if configured + # Build options for kwargs propagation to agent tools + run_kwargs = ctx.run_kwargs + options: dict[str, Any] | None = None + if run_kwargs: + # Merge caller-provided options to avoid duplicate keyword argument + options = dict(run_kwargs.get("options") or {}) + options["additional_function_arguments"] = run_kwargs + # Exclude 'options' from splat to avoid TypeError on duplicate keyword + run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"} + while True: # Invoke the agent try: @@ -337,7 +347,7 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl updates: list[Any] = [] tool_calls: list[Any] = [] - async for chunk in agent.run(messages, stream=True): + async for chunk in agent.run(messages, stream=True, options=options, **run_kwargs): updates.append(chunk) # Yield streaming events for text chunks @@ -403,7 +413,7 @@ async def handle_invoke_azure_agent(ctx: ActionContext) -> AsyncGenerator[Workfl except TypeError: # Agent doesn't support streaming, fall back to non-streaming - response = await agent.run(messages) + response = await agent.run(messages, options=options, **run_kwargs) text = response.text response_messages = response.messages @@ -570,6 +580,16 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf logger.debug(f"InvokePromptAgent: calling '{agent_name}' with {len(messages)} messages") + # Build options for kwargs propagation to agent tools + prompt_run_kwargs = ctx.run_kwargs + prompt_options: dict[str, Any] | None = None + if prompt_run_kwargs: + # Merge caller-provided options to avoid duplicate keyword argument + prompt_options = dict(prompt_run_kwargs.get("options") or {}) + prompt_options["additional_function_arguments"] = prompt_run_kwargs + # Exclude 'options' from splat to avoid TypeError on duplicate keyword + prompt_run_kwargs = {k: v for k, v in prompt_run_kwargs.items() if k != "options"} + # Invoke the agent try: if hasattr(agent, "run"): @@ -577,7 +597,7 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf try: updates: list[Any] = [] - async for chunk in agent.run(messages, stream=True): + async for chunk in agent.run(messages, stream=True, options=prompt_options, **prompt_run_kwargs): updates.append(chunk) if hasattr(chunk, "text") and chunk.text: @@ -607,7 +627,7 @@ async def handle_invoke_prompt_agent(ctx: ActionContext) -> AsyncGenerator[Workf except TypeError: # Agent doesn't support streaming, fall back to non-streaming - response = await agent.run(messages) + response = await agent.run(messages, options=prompt_options, **prompt_run_kwargs) text = response.text response_messages = response.messages diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py index 9bb868135b..229f6ea3b0 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -37,7 +37,13 @@ from agent_framework._workflows import ( WorkflowContext, ) from agent_framework._workflows._state import State -from powerfx import Engine + +try: + from powerfx import Engine +except (ImportError, RuntimeError): + # ImportError: powerfx package not installed + # RuntimeError: .NET runtime not available or misconfigured + Engine = None # type: ignore[assignment, misc] if sys.version_info >= (3, 11): from typing import TypedDict # type: ignore # pragma: no cover @@ -339,7 +345,8 @@ class DeclarativeWorkflowState: undefined variables (matching legacy fallback parser behavior). Raises: - ImportError: If the powerfx package is not installed. + RuntimeError: If the powerfx package is not installed and the + expression requires PowerFx evaluation. """ if not expression: return expression @@ -363,6 +370,13 @@ class DeclarativeWorkflowState: # Replace them with their evaluated results before sending to PowerFx formula = self._preprocess_custom_functions(formula) + if Engine is None: + raise RuntimeError( + f"PowerFx is not available (dotnet runtime not installed). " + f"Expression '={formula[:80]}' cannot be evaluated. " + f"Install dotnet and the powerfx package for full PowerFx support." + ) + engine = Engine() symbols = self._to_powerfx_symbols() try: diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py index f28d283e60..44c9e958c2 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_agents.py @@ -656,10 +656,22 @@ class InvokeAzureAgentExecutor(DeclarativeActionExecutor): if isinstance(messages_for_agent, list) and messages_for_agent: _validate_conversation_history(messages_for_agent, agent_name) + # Retrieve kwargs passed to workflow.run() so they propagate to agent tools + from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY + + run_kwargs: dict[str, Any] = ctx.get_state(WORKFLOW_RUN_KWARGS_KEY, {}) + options: dict[str, Any] | None = None + if run_kwargs: + # Merge caller-provided options to avoid duplicate keyword argument + options = dict(run_kwargs.get("options") or {}) + options["additional_function_arguments"] = run_kwargs + # Exclude 'options' from splat to avoid TypeError on duplicate keyword + run_kwargs = {k: v for k, v in run_kwargs.items() if k != "options"} + # Use run() method to get properly structured messages (including tool calls and results) # This is critical for multi-turn conversations where tool calls must be followed # by their results in the message history - result: Any = await agent.run(messages_for_agent) + result: Any = await agent.run(messages_for_agent, options=options, **run_kwargs) if hasattr(result, "text") and result.text: accumulated_response = str(result.text) if auto_send: diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py b/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py index cc529a2c8a..c8a2039073 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_handlers.py @@ -10,7 +10,7 @@ has a corresponding handler registered via the @action_handler decorator. from __future__ import annotations from collections.abc import AsyncGenerator, Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any, Protocol, runtime_checkable from agent_framework import get_logger @@ -44,6 +44,9 @@ class ActionContext: bindings: dict[str, Any] """Function bindings for tool calls.""" + run_kwargs: dict[str, Any] = field(default_factory=dict) + """Kwargs from workflow.run() to forward to agent invocations.""" + @property def action_id(self) -> str | None: """Get the action's unique identifier.""" diff --git a/python/packages/declarative/tests/test_declarative_loader.py b/python/packages/declarative/tests/test_declarative_loader.py index 8b70634aeb..a200a69310 100644 --- a/python/packages/declarative/tests/test_declarative_loader.py +++ b/python/packages/declarative/tests/test_declarative_loader.py @@ -1,8 +1,10 @@ # Copyright (c) Microsoft. All rights reserved. +import builtins import sys from pathlib import Path from typing import Any +from unittest.mock import AsyncMock, MagicMock, patch import pytest import yaml @@ -905,3 +907,105 @@ tools: # Verify project_connection_id is set from connection name assert mcp_tool.get("project_connection_id") == "my-oauth-connection" + + +class TestProviderResponseFormat: + """response_format from outputSchema must be passed inside default_options.""" + + @staticmethod + def _make_mock_prompt_agent(*, with_output_schema: bool = False) -> MagicMock: + """Create a mock PromptAgent to avoid serialization complexity.""" + mock_model = MagicMock() + mock_model.id = "gpt-4" + mock_model.connection = None + + agent = MagicMock() + agent.name = "test-agent" + agent.description = "test" + agent.instructions = "be helpful" + agent.model = mock_model + agent.tools = None + + if with_output_schema: + mock_schema = MagicMock() + mock_schema.to_json_schema.return_value = { + "type": "object", + "properties": {"answer": {"type": "string"}}, + } + agent.outputSchema = mock_schema + else: + agent.outputSchema = None + + return agent + + @staticmethod + def _make_mock_provider() -> tuple[MagicMock, AsyncMock]: + """Create a mock provider class and its instance.""" + mock_agent = MagicMock() + mock_provider_instance = AsyncMock() + mock_provider_instance.create_agent = AsyncMock(return_value=mock_agent) + mock_provider_class = MagicMock(return_value=mock_provider_instance) + return mock_provider_class, mock_provider_instance + + @pytest.mark.asyncio + async def test_response_format_in_default_options(self): + """Provider.create_agent() should receive response_format inside default_options.""" + from agent_framework_declarative._loader import AgentFactory + + prompt_agent = self._make_mock_prompt_agent(with_output_schema=True) + mock_provider_class, mock_provider_instance = self._make_mock_provider() + + mapping = {"package": "some_module", "name": "SomeProvider"} + factory = AgentFactory() + + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "some_module": + mod = MagicMock() + mod.SomeProvider = mock_provider_class + return mod + return original_import(name, *args, **kwargs) + + with ( + patch.object(builtins, "__import__", side_effect=mock_import), + patch.object(factory, "_parse_tools", return_value=None), + ): + await factory._create_agent_with_provider(prompt_agent, mapping) + + mock_provider_instance.create_agent.assert_called_once() + call_kwargs = mock_provider_instance.create_agent.call_args.kwargs + + assert "response_format" not in call_kwargs + default_options = call_kwargs.get("default_options") + assert default_options is not None + assert "response_format" in default_options + + @pytest.mark.asyncio + async def test_no_default_options_without_output_schema(self): + """When there's no outputSchema, default_options should be None.""" + from agent_framework_declarative._loader import AgentFactory + + prompt_agent = self._make_mock_prompt_agent(with_output_schema=False) + mock_provider_class, mock_provider_instance = self._make_mock_provider() + + mapping = {"package": "some_module", "name": "SomeProvider"} + factory = AgentFactory() + + original_import = builtins.__import__ + + def mock_import(name, *args, **kwargs): + if name == "some_module": + mod = MagicMock() + mod.SomeProvider = mock_provider_class + return mod + return original_import(name, *args, **kwargs) + + with ( + patch.object(builtins, "__import__", side_effect=mock_import), + patch.object(factory, "_parse_tools", return_value=None), + ): + await factory._create_agent_with_provider(prompt_agent, mapping) + + call_kwargs = mock_provider_instance.create_agent.call_args.kwargs + assert call_kwargs.get("default_options") is None diff --git a/python/packages/declarative/tests/test_graph_executors.py b/python/packages/declarative/tests/test_graph_executors.py index 0a4433b095..754274db59 100644 --- a/python/packages/declarative/tests/test_graph_executors.py +++ b/python/packages/declarative/tests/test_graph_executors.py @@ -2,6 +2,7 @@ """Tests for the graph-based declarative workflow executors.""" +from typing import Any from unittest.mock import AsyncMock, MagicMock import pytest @@ -1295,3 +1296,203 @@ class TestExtractJsonFromResponse: text = 'First: {"status": "pending"} then later: {"status": "complete", "id": 42}' result = _extract_json_from_response(text) assert result == {"status": "complete", "id": 42} + + +class TestPowerFxConditionalImport: + """The _declarative_base module should be importable without dotnet/powerfx.""" + + def test_import_guard_exists(self): + """The powerfx import must be wrapped in try/except.""" + import agent_framework_declarative._workflows._declarative_base as base_mod + + assert hasattr(base_mod, "DeclarativeWorkflowState") + assert hasattr(base_mod, "Engine") + + # Engine should either be the real class or None — never an ImportError + engine = base_mod.Engine + assert engine is None or callable(engine) + + def test_eval_raises_when_engine_unavailable(self): + """eval() should raise RuntimeError when Engine is None.""" + import agent_framework_declarative._workflows._declarative_base as base_mod + + mock_state = MagicMock() + mock_state._data: dict[str, Any] = {} + mock_state.get = MagicMock(side_effect=lambda k, d=None: mock_state._data.get(k, d)) + mock_state.set = MagicMock(side_effect=lambda k, v: mock_state._data.__setitem__(k, v)) + + state = DeclarativeWorkflowState(mock_state) + state.initialize({"name": "test"}) + + original_engine = base_mod.Engine + try: + base_mod.Engine = None + with pytest.raises(RuntimeError, match="PowerFx is not available"): + state.eval("=Local.counter + 1") + finally: + base_mod.Engine = original_engine + + def test_eval_passes_through_plain_strings_without_engine(self): + """Non-PowerFx strings (no leading '=') should work without Engine.""" + import agent_framework_declarative._workflows._declarative_base as base_mod + + mock_state = MagicMock() + mock_state._data: dict[str, Any] = {} + mock_state.get = MagicMock(side_effect=lambda k, d=None: mock_state._data.get(k, d)) + mock_state.set = MagicMock(side_effect=lambda k, v: mock_state._data.__setitem__(k, v)) + + state = DeclarativeWorkflowState(mock_state) + state.initialize() + + original_engine = base_mod.Engine + try: + base_mod.Engine = None + assert state.eval("hello world") == "hello world" + assert state.eval("") == "" + assert state.eval(42) == 42 + finally: + base_mod.Engine = original_engine + + +class TestExecutorKwargsForwarding: + """Workflow run kwargs should be forwarded through executor agent invocations.""" + + @pytest.mark.asyncio + async def test_invoke_agent_forwards_kwargs(self): + """InvokeAzureAgentExecutor should forward run_kwargs to agent.run().""" + from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY + from agent_framework._workflows._state import State + + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + # Create a mock State with kwargs stored + mock_state = MagicMock(spec=State) + state_data: dict[str, Any] = {} + + def mock_get(key, default=None): + return state_data.get(key, default) + + def mock_set(key, value): + state_data[key] = value + + mock_state.get = MagicMock(side_effect=mock_get) + mock_state.set = MagicMock(side_effect=mock_set) + + # Store kwargs in state like Workflow.run() does + test_kwargs = {"user_token": "abc123", "service_config": {"endpoint": "http://test"}} + state_data[WORKFLOW_RUN_KWARGS_KEY] = test_kwargs + + # Initialize declarative state + dws = DeclarativeWorkflowState(mock_state) + dws.initialize({"input": "hello"}) + + # Create a mock agent + mock_response = MagicMock() + mock_response.text = "response text" + mock_response.messages = [] + mock_response.tool_calls = [] + mock_agent = AsyncMock() + mock_agent.run = AsyncMock(return_value=mock_response) + + # Create a mock workflow context + mock_ctx = MagicMock() + mock_ctx.get_state = MagicMock(side_effect=mock_get) + mock_ctx.yield_output = AsyncMock() + + executor = InvokeAzureAgentExecutor.__new__(InvokeAzureAgentExecutor) + executor._agents = {"test_agent": mock_agent} + + await executor._invoke_agent_and_store_results( + agent=mock_agent, + agent_name="test_agent", + input_text="hello", + state=dws, + ctx=mock_ctx, + messages_var=None, + response_obj_var=None, + result_property=None, + auto_send=True, + ) + + # Verify agent.run was called with kwargs + mock_agent.run.assert_called_once() + call_kwargs = mock_agent.run.call_args + + # Check options contains additional_function_arguments + assert "options" in call_kwargs.kwargs + assert call_kwargs.kwargs["options"]["additional_function_arguments"] == test_kwargs + + # Check direct kwargs were passed + assert call_kwargs.kwargs.get("user_token") == "abc123" + assert call_kwargs.kwargs.get("service_config") == {"endpoint": "http://test"} + + @pytest.mark.asyncio + async def test_invoke_agent_merges_caller_options(self): + """Caller-provided options in run_kwargs should be merged, not cause TypeError.""" + from agent_framework._workflows._const import WORKFLOW_RUN_KWARGS_KEY + from agent_framework._workflows._state import State + + from agent_framework_declarative._workflows._executors_agents import ( + InvokeAzureAgentExecutor, + ) + + mock_state = MagicMock(spec=State) + state_data: dict[str, Any] = {} + + def mock_get(key, default=None): + return state_data.get(key, default) + + def mock_set(key, value): + state_data[key] = value + + mock_state.get = MagicMock(side_effect=mock_get) + mock_state.set = MagicMock(side_effect=mock_set) + + # Include 'options' in run_kwargs to test merge behavior + test_kwargs = { + "user_token": "abc123", + "options": {"temperature": 0.5}, + } + state_data[WORKFLOW_RUN_KWARGS_KEY] = test_kwargs + + dws = DeclarativeWorkflowState(mock_state) + dws.initialize({"input": "hello"}) + + mock_response = MagicMock() + mock_response.text = "response text" + mock_response.messages = [] + mock_response.tool_calls = [] + mock_agent = AsyncMock() + mock_agent.run = AsyncMock(return_value=mock_response) + + mock_ctx = MagicMock() + mock_ctx.get_state = MagicMock(side_effect=mock_get) + mock_ctx.yield_output = AsyncMock() + + executor = InvokeAzureAgentExecutor.__new__(InvokeAzureAgentExecutor) + executor._agents = {"test_agent": mock_agent} + + await executor._invoke_agent_and_store_results( + agent=mock_agent, + agent_name="test_agent", + input_text="hello", + state=dws, + ctx=mock_ctx, + messages_var=None, + response_obj_var=None, + result_property=None, + auto_send=True, + ) + + mock_agent.run.assert_called_once() + call_kwargs = mock_agent.run.call_args + + # Caller options should be merged with additional_function_arguments + merged_options = call_kwargs.kwargs["options"] + assert merged_options["temperature"] == 0.5 + assert "additional_function_arguments" in merged_options + + # Direct kwargs should be passed without 'options' (no duplicate keyword) + assert call_kwargs.kwargs.get("user_token") == "abc123" diff --git a/python/packages/declarative/tests/test_workflow_handlers.py b/python/packages/declarative/tests/test_workflow_handlers.py index 88aa565c9b..23c37db295 100644 --- a/python/packages/declarative/tests/test_workflow_handlers.py +++ b/python/packages/declarative/tests/test_workflow_handlers.py @@ -4,6 +4,7 @@ from collections.abc import AsyncGenerator from typing import Any +from unittest.mock import AsyncMock, MagicMock import pytest @@ -29,6 +30,7 @@ def create_action_context( inputs: dict[str, Any] | None = None, agents: dict[str, Any] | None = None, bindings: dict[str, Any] | None = None, + run_kwargs: dict[str, Any] | None = None, ) -> ActionContext: """Helper to create an ActionContext for testing.""" state = WorkflowState(inputs=inputs or {}) @@ -47,6 +49,7 @@ def create_action_context( execute_actions=execute_actions, agents=agents or {}, bindings=bindings or {}, + run_kwargs=run_kwargs or {}, ) async for event in handler(ctx): yield event @@ -57,6 +60,7 @@ def create_action_context( execute_actions=execute_actions, agents=agents or {}, bindings=bindings or {}, + run_kwargs=run_kwargs or {}, ) @@ -422,3 +426,128 @@ class TestTryCatchHandler: assert ctx.state.get("Local.try") == "ran" assert ctx.state.get("Local.finally") == "ran" + + +class TestActionContextKwargs: + """ActionContext should carry and forward run_kwargs to agent invocations.""" + + @pytest.mark.asyncio + async def test_action_context_carries_run_kwargs(self): + """ActionContext should store and expose run_kwargs.""" + ctx = create_action_context( + {"kind": "SetValue", "path": "Local.x", "value": "1"}, + run_kwargs={"user_token": "test123"}, + ) + assert ctx.run_kwargs == {"user_token": "test123"} + + @pytest.mark.asyncio + async def test_action_context_defaults_to_empty_kwargs(self): + """ActionContext.run_kwargs should default to empty dict.""" + ctx = create_action_context( + {"kind": "SetValue", "path": "Local.x", "value": "1"}, + ) + assert ctx.run_kwargs == {} + + @pytest.mark.asyncio + async def test_invoke_agent_handler_forwards_kwargs(self): + """handle_invoke_azure_agent should forward ctx.run_kwargs to agent.run().""" + import agent_framework_declarative._workflows._actions_agents # noqa: F401 + + mock_response = MagicMock() + mock_response.text = "response" + mock_response.messages = [] + mock_response.tool_calls = [] + + async def non_streaming_run(*args, **kwargs): + if kwargs.get("stream"): + raise TypeError("no streaming") + return mock_response + + mock_agent = AsyncMock() + mock_agent.run = AsyncMock(side_effect=non_streaming_run) + + test_kwargs = {"user_token": "secret", "api_key": "key123"} + + state = WorkflowState() + state.add_conversation_message(MagicMock(role="user", text="hello")) + + ctx = create_action_context( + action={ + "kind": "InvokeAzureAgent", + "agent": "my_agent", + }, + agents={"my_agent": mock_agent}, + run_kwargs=test_kwargs, + ) + + handler = get_action_handler("InvokeAzureAgent") + _ = [e async for e in handler(ctx)] + + assert mock_agent.run.call_count >= 1 + + # Find the non-streaming fallback call + for call in mock_agent.run.call_args_list: + call_kw = call.kwargs + if not call_kw.get("stream"): + assert call_kw.get("user_token") == "secret" + assert call_kw.get("api_key") == "key123" + assert call_kw.get("options") == {"additional_function_arguments": test_kwargs} + break + else: + # All calls were streaming — check the streaming call + call_kw = mock_agent.run.call_args_list[0].kwargs + assert call_kw.get("user_token") == "secret" + assert call_kw.get("api_key") == "key123" + + @pytest.mark.asyncio + async def test_invoke_agent_handler_merges_caller_options(self): + """Caller-provided options in run_kwargs should be merged, not cause TypeError.""" + import agent_framework_declarative._workflows._actions_agents # noqa: F401 + + mock_response = MagicMock() + mock_response.text = "response" + mock_response.messages = [] + mock_response.tool_calls = [] + + async def non_streaming_run(*args, **kwargs): + if kwargs.get("stream"): + raise TypeError("no streaming") + return mock_response + + mock_agent = AsyncMock() + mock_agent.run = AsyncMock(side_effect=non_streaming_run) + + # Include 'options' in run_kwargs to test merge behavior + test_kwargs = {"user_token": "secret", "options": {"temperature": 0.7}} + + state = WorkflowState() + state.add_conversation_message(MagicMock(role="user", text="hello")) + + ctx = create_action_context( + action={ + "kind": "InvokeAzureAgent", + "agent": "my_agent", + }, + agents={"my_agent": mock_agent}, + run_kwargs=test_kwargs, + ) + + handler = get_action_handler("InvokeAzureAgent") + _ = [e async for e in handler(ctx)] + + assert mock_agent.run.call_count >= 1 + + # Find the non-streaming fallback call + for call in mock_agent.run.call_args_list: + call_kw = call.kwargs + if not call_kw.get("stream"): + # Caller options should be merged with additional_function_arguments + assert call_kw["options"]["temperature"] == 0.7 + assert "additional_function_arguments" in call_kw["options"] + # Direct kwargs should not include 'options' (no duplicate keyword) + assert call_kw.get("user_token") == "secret" + break + else: + call_kw = mock_agent.run.call_args_list[0].kwargs + assert call_kw["options"]["temperature"] == 0.7 + assert "additional_function_arguments" in call_kw["options"] diff --git a/python/packages/devui/agent_framework_devui/_executor.py b/python/packages/devui/agent_framework_devui/_executor.py index b55a57cf44..92e6301b66 100644 --- a/python/packages/devui/agent_framework_devui/_executor.py +++ b/python/packages/devui/agent_framework_devui/_executor.py @@ -430,7 +430,7 @@ class AgentFrameworkExecutor: elif hil_responses: # Only auto-resume from latest checkpoint when we have HIL responses # Regular "Run" clicks should start fresh, not resume from checkpoints - checkpoints = await checkpoint_storage.list_checkpoints() # No workflow_id filter needed! + checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name) if checkpoints: latest = max(checkpoints, key=lambda cp: cp.timestamp) checkpoint_id = latest.checkpoint_id diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index 374bab9962..e7994d3d3b 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -1059,7 +1059,7 @@ class DevServer: # Extract checkpoint_id from item_id (format: "checkpoint_{checkpoint_id}") checkpoint_id = item_id[len("checkpoint_") :] storage = executor.checkpoint_manager.get_checkpoint_storage(conversation_id) - deleted = await storage.delete_checkpoint(checkpoint_id) + deleted = await storage.delete(checkpoint_id) if not deleted: raise HTTPException(status_code=404, detail="Checkpoint not found") diff --git a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py index 5cf9e8c85d..ece84bc483 100644 --- a/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py +++ b/python/packages/foundry_local/agent_framework_foundry_local/_foundry_local_client.py @@ -4,7 +4,7 @@ from __future__ import annotations import sys from collections.abc import Sequence -from typing import Any, ClassVar, Generic +from typing import Any, Generic from agent_framework import ( ChatAndFunctionMiddlewareTypes, @@ -13,7 +13,7 @@ from agent_framework import ( FunctionInvocationConfiguration, FunctionInvocationLayer, ) -from agent_framework._pydantic import AFBaseSettings +from agent_framework._settings import load_settings from agent_framework.exceptions import ServiceInitializationError from agent_framework.observability import ChatTelemetryLayer from agent_framework.openai._chat_client import RawOpenAIChatClient @@ -115,25 +115,19 @@ FoundryLocalChatOptionsT = TypeVar( # endregion -class FoundryLocalSettings(AFBaseSettings): +class FoundryLocalSettings(TypedDict, total=False): """Foundry local model settings. The settings are first loaded from environment variables with the prefix 'FOUNDRY_LOCAL_'. If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. If the settings are not found in the .env file, the settings - are ignored; however, validation will fail alerting that the settings are missing. + with the encoding 'utf-8'. - Attributes: + Keys: model_id: The name of the model deployment to use. (Env var FOUNDRY_LOCAL_MODEL_ID) - Parameters: - env_file_path: If provided, the .env settings are read from this file path location. - env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. """ - env_prefix: ClassVar[str] = "FOUNDRY_LOCAL_" - - model_id: str + model_id: str | None class FoundryLocalClient( @@ -247,21 +241,27 @@ class FoundryLocalClient( type that is not supported by the model, it will not be found. """ - settings = FoundryLocalSettings( - model_id=model_id, # type: ignore + settings = load_settings( + FoundryLocalSettings, + env_prefix="FOUNDRY_LOCAL_", + required_fields=["model_id"], + model_id=model_id, env_file_path=env_file_path, env_file_encoding=env_file_encoding, ) manager = FoundryLocalManager(bootstrap=bootstrap, timeout=timeout) model_info = manager.get_model_info( - alias_or_model_id=settings.model_id, + alias_or_model_id=settings["model_id"], device=device, ) if model_info is None: message = ( - f"Model with ID or alias '{settings.model_id}:{device.value}' not found in Foundry Local." + f"Model with ID or alias '{settings['model_id']}:{device.value}' not found in Foundry Local." if device - else f"Model with ID or alias '{settings.model_id}' for your current device not found in Foundry Local." + else ( + f"Model with ID or alias '{settings['model_id']}' for your current device " + "not found in Foundry Local." + ) ) raise ServiceInitializationError(message) if prepare_model: diff --git a/python/packages/foundry_local/tests/test_foundry_local_client.py b/python/packages/foundry_local/tests/test_foundry_local_client.py index 031461d926..88b9fb4260 100644 --- a/python/packages/foundry_local/tests/test_foundry_local_client.py +++ b/python/packages/foundry_local/tests/test_foundry_local_client.py @@ -4,8 +4,8 @@ from unittest.mock import MagicMock, patch import pytest from agent_framework import SupportsChatGetResponse -from agent_framework.exceptions import ServiceInitializationError -from pydantic import ValidationError +from agent_framework._settings import load_settings +from agent_framework.exceptions import ServiceInitializationError, SettingNotFoundError from agent_framework_foundry_local import FoundryLocalClient from agent_framework_foundry_local._foundry_local_client import FoundryLocalSettings @@ -15,31 +15,43 @@ from agent_framework_foundry_local._foundry_local_client import FoundryLocalSett def test_foundry_local_settings_init_from_env(foundry_local_unit_test_env: dict[str, str]) -> None: """Test FoundryLocalSettings initialization from environment variables.""" - settings = FoundryLocalSettings(env_file_path="test.env") + settings = load_settings(FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", env_file_path="test.env") - assert settings.model_id == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"] + assert settings["model_id"] == foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"] def test_foundry_local_settings_init_with_explicit_values() -> None: """Test FoundryLocalSettings initialization with explicit values.""" - settings = FoundryLocalSettings(model_id="custom-model-id", env_file_path="test.env") + settings = load_settings( + FoundryLocalSettings, + env_prefix="FOUNDRY_LOCAL_", + model_id="custom-model-id", + env_file_path="test.env", + ) - assert settings.model_id == "custom-model-id" + assert settings["model_id"] == "custom-model-id" @pytest.mark.parametrize("exclude_list", [["FOUNDRY_LOCAL_MODEL_ID"]], indirect=True) def test_foundry_local_settings_missing_model_id(foundry_local_unit_test_env: dict[str, str]) -> None: - """Test FoundryLocalSettings when model_id is missing raises ValidationError.""" - with pytest.raises(ValidationError): - FoundryLocalSettings(env_file_path="test.env") + """Test FoundryLocalSettings when model_id is missing raises error.""" + with pytest.raises(SettingNotFoundError, match="Required setting 'model_id'"): + load_settings( + FoundryLocalSettings, + env_prefix="FOUNDRY_LOCAL_", + required_fields=["model_id"], + env_file_path="test.env", + ) def test_foundry_local_settings_explicit_overrides_env(foundry_local_unit_test_env: dict[str, str]) -> None: """Test that explicit values override environment variables.""" - settings = FoundryLocalSettings(model_id="override-model-id", env_file_path="test.env") + settings = load_settings( + FoundryLocalSettings, env_prefix="FOUNDRY_LOCAL_", model_id="override-model-id", env_file_path="test.env" + ) - assert settings.model_id == "override-model-id" - assert settings.model_id != foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"] + assert settings["model_id"] == "override-model-id" + assert settings["model_id"] != foundry_local_unit_test_env["FOUNDRY_LOCAL_MODEL_ID"] # Client Initialization Tests diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py index 38b31f4e2e..d1475f04a3 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_agent.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_agent.py @@ -21,9 +21,10 @@ from agent_framework import ( ResponseStream, normalize_messages, ) +from agent_framework._settings import load_settings from agent_framework._tools import FunctionTool from agent_framework._types import normalize_tools -from agent_framework.exceptions import ServiceException, ServiceInitializationError +from agent_framework.exceptions import ServiceException from copilot import CopilotClient, CopilotSession from copilot.generated.session_events import SessionEvent, SessionEventType from copilot.types import ( @@ -38,7 +39,6 @@ from copilot.types import ( ToolResult, ) from copilot.types import Tool as CopilotTool -from pydantic import ValidationError from ._settings import GitHubCopilotSettings @@ -207,17 +207,16 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): on_permission_request: PermissionHandlerType | None = opts.pop("on_permission_request", None) mcp_servers: dict[str, MCPServerConfig] | None = opts.pop("mcp_servers", None) - try: - self._settings = GitHubCopilotSettings( - cli_path=cli_path, - model=model, - timeout=timeout, - log_level=log_level, - env_file_path=env_file_path, - env_file_encoding=env_file_encoding, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create GitHub Copilot settings.", ex) from ex + self._settings = load_settings( + GitHubCopilotSettings, + env_prefix="GITHUB_COPILOT_", + cli_path=cli_path, + model=model, + timeout=timeout, + log_level=log_level, + env_file_path=env_file_path, + env_file_encoding=env_file_encoding, + ) self._tools = normalize_tools(tools) self._permission_handler = on_permission_request @@ -249,10 +248,10 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): if self._client is None: client_options: CopilotClientOptions = {} - if self._settings.cli_path: - client_options["cli_path"] = self._settings.cli_path - if self._settings.log_level: - client_options["log_level"] = self._settings.log_level # type: ignore[typeddict-item] + if self._settings["cli_path"]: + client_options["cli_path"] = self._settings["cli_path"] + if self._settings["log_level"]: + client_options["log_level"] = self._settings["log_level"] # type: ignore[typeddict-item] self._client = CopilotClient(client_options if client_options else None) @@ -355,7 +354,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): thread = self.get_new_thread() opts: dict[str, Any] = dict(options) if options else {} - timeout = opts.pop("timeout", None) or self._settings.timeout or DEFAULT_TIMEOUT_SECONDS + timeout = opts.pop("timeout", None) or self._settings["timeout"] or DEFAULT_TIMEOUT_SECONDS session = await self._get_or_create_session(thread, streaming=False, runtime_options=opts) input_messages = normalize_messages(messages) @@ -578,7 +577,7 @@ class GitHubCopilotAgent(BaseAgent, Generic[OptionsT]): opts = runtime_options or {} config: SessionConfig = {"streaming": streaming} - model = opts.get("model") or self._settings.model + model = opts.get("model") or self._settings["model"] if model: config["model"] = model # type: ignore[typeddict-item] diff --git a/python/packages/github_copilot/agent_framework_github_copilot/_settings.py b/python/packages/github_copilot/agent_framework_github_copilot/_settings.py index 67315539d5..884d23e9a8 100644 --- a/python/packages/github_copilot/agent_framework_github_copilot/_settings.py +++ b/python/packages/github_copilot/agent_framework_github_copilot/_settings.py @@ -1,19 +1,16 @@ # Copyright (c) Microsoft. All rights reserved. -from typing import ClassVar - -from agent_framework._pydantic import AFBaseSettings +from typing import TypedDict -class GitHubCopilotSettings(AFBaseSettings): +class GitHubCopilotSettings(TypedDict, total=False): """GitHub Copilot model settings. The settings are first loaded from environment variables with the prefix 'GITHUB_COPILOT_'. If the environment variables are not found, the settings can be loaded from a .env file - with the encoding 'utf-8'. If the settings are not found in the .env file, the settings - are ignored; however, validation will fail alerting that the settings are missing. + with the encoding 'utf-8'. - Keyword Args: + Keys: cli_path: Path to the Copilot CLI executable. Can be set via environment variable GITHUB_COPILOT_CLI_PATH. model: Model to use (e.g., "gpt-5", "claude-sonnet-4"). @@ -22,28 +19,9 @@ class GitHubCopilotSettings(AFBaseSettings): Can be set via environment variable GITHUB_COPILOT_TIMEOUT. log_level: CLI log level. Can be set via environment variable GITHUB_COPILOT_LOG_LEVEL. - env_file_path: If provided, the .env settings are read from this file path location. - env_file_encoding: The encoding of the .env file, defaults to 'utf-8'. - - Examples: - .. code-block:: python - - from agent_framework_github_copilot import GitHubCopilotSettings - - # Using environment variables - # Set GITHUB_COPILOT_MODEL=gpt-5 - settings = GitHubCopilotSettings() - - # Or passing parameters directly - settings = GitHubCopilotSettings(model="claude-sonnet-4", timeout=120) - - # Or loading from a .env file - settings = GitHubCopilotSettings(env_file_path="path/to/.env") """ - env_prefix: ClassVar[str] = "GITHUB_COPILOT_" - - cli_path: str | None = None - model: str | None = None - timeout: float | None = None - log_level: str | None = None + cli_path: str | None + model: str | None + timeout: float | None + log_level: str | None diff --git a/python/packages/github_copilot/tests/test_github_copilot_agent.py b/python/packages/github_copilot/tests/test_github_copilot_agent.py index b2b7b2ebee..0ce0ca0307 100644 --- a/python/packages/github_copilot/tests/test_github_copilot_agent.py +++ b/python/packages/github_copilot/tests/test_github_copilot_agent.py @@ -122,8 +122,8 @@ class TestGitHubCopilotAgentInit: agent: GitHubCopilotAgent[GitHubCopilotOptions] = GitHubCopilotAgent( default_options={"model": "claude-sonnet-4", "timeout": 120} ) - assert agent._settings.model == "claude-sonnet-4" # type: ignore - assert agent._settings.timeout == 120 # type: ignore + assert agent._settings["model"] == "claude-sonnet-4" # type: ignore + assert agent._settings["timeout"] == 120 # type: ignore def test_init_with_tools(self) -> None: """Test initialization with function tools.""" diff --git a/python/packages/ollama/agent_framework_ollama/_chat_client.py b/python/packages/ollama/agent_framework_ollama/_chat_client.py index 6ead403c5b..a074562a83 100644 --- a/python/packages/ollama/agent_framework_ollama/_chat_client.py +++ b/python/packages/ollama/agent_framework_ollama/_chat_client.py @@ -30,9 +30,8 @@ from agent_framework import ( UsageDetails, get_logger, ) -from agent_framework._pydantic import AFBaseSettings +from agent_framework._settings import load_settings from agent_framework.exceptions import ( - ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException, ) @@ -42,7 +41,7 @@ from ollama import AsyncClient # Rename imported types to avoid naming conflicts with Agent Framework types from ollama._types import ChatResponse as OllamaChatResponse from ollama._types import Message as OllamaMessage -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel if sys.version_info >= (3, 13): from typing import TypeVar # type: ignore # pragma: no cover @@ -275,13 +274,11 @@ OllamaChatOptionsT = TypeVar("OllamaChatOptionsT", bound=TypedDict, default="Oll # endregion -class OllamaSettings(AFBaseSettings): +class OllamaSettings(TypedDict, total=False): """Ollama settings.""" - env_prefix: ClassVar[str] = "OLLAMA_" - - host: str | None = None - model_id: str | None = None + host: str | None + model_id: str | None logger = get_logger("agent_framework.ollama") @@ -322,23 +319,19 @@ class OllamaChatClient( env_file_encoding: The encoding to use when reading the dotenv (.env) file. Defaults to 'utf-8'. **kwargs: Additional keyword arguments passed to BaseChatClient. """ - try: - ollama_settings = OllamaSettings( - host=host, - model_id=model_id, - env_file_encoding=env_file_encoding, - env_file_path=env_file_path, - ) - except ValidationError as ex: - raise ServiceInitializationError("Failed to create Ollama settings.", ex) from ex + ollama_settings = load_settings( + OllamaSettings, + env_prefix="OLLAMA_", + required_fields=["model_id"], + host=host, + model_id=model_id, + env_file_encoding=env_file_encoding, + env_file_path=env_file_path, + ) - if ollama_settings.model_id is None: - raise ServiceInitializationError( - "Ollama chat model ID must be provided via model_id or OLLAMA_MODEL_ID environment variable." - ) - - self.model_id = ollama_settings.model_id - self.client = client or AsyncClient(host=ollama_settings.host) + self.model_id = ollama_settings["model_id"] + # we can just pass in None for the host, the default is set by the Ollama package. + self.client = client or AsyncClient(host=ollama_settings.get("host")) # Save Host URL for serialization with to_dict() self.host = str(self.client._client.base_url) # pyright: ignore[reportUnknownMemberType,reportPrivateUsage,reportUnknownArgumentType] diff --git a/python/packages/ollama/tests/test_ollama_chat_client.py b/python/packages/ollama/tests/test_ollama_chat_client.py index 8d179b982d..14f21332d9 100644 --- a/python/packages/ollama/tests/test_ollama_chat_client.py +++ b/python/packages/ollama/tests/test_ollama_chat_client.py @@ -15,9 +15,9 @@ from agent_framework import ( tool, ) from agent_framework.exceptions import ( - ServiceInitializationError, ServiceInvalidRequestError, ServiceResponseException, + SettingNotFoundError, ) from ollama import AsyncClient from ollama._types import ChatResponse as OllamaChatResponse @@ -182,7 +182,7 @@ def test_init_client(ollama_unit_test_env: dict[str, str]) -> None: @pytest.mark.parametrize("exclude_list", [["OLLAMA_MODEL_ID"]], indirect=True) def test_with_invalid_settings(ollama_unit_test_env: dict[str, str]) -> None: - with pytest.raises(ServiceInitializationError): + with pytest.raises(SettingNotFoundError, match="Required setting 'model_id'"): OllamaChatClient( host="http://localhost:12345", model_id=None, diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py index d5ead8d9e7..d7ac1576c7 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_group_chat.py @@ -32,7 +32,6 @@ from agent_framework import Agent, AgentThread, Message, SupportsAgentRun from agent_framework._workflows._agent_executor import AgentExecutor, AgentExecutorRequest, AgentExecutorResponse from agent_framework._workflows._agent_utils import resolve_agent_id from agent_framework._workflows._checkpoint import CheckpointStorage -from agent_framework._workflows._conversation_state import decode_chat_messages, encode_chat_messages from agent_framework._workflows._executor import Executor from agent_framework._workflows._workflow import Workflow from agent_framework._workflows._workflow_builder import WorkflowBuilder @@ -476,7 +475,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): async def on_checkpoint_save(self) -> dict[str, Any]: """Capture current orchestrator state for checkpointing.""" state = await super().on_checkpoint_save() - state["cache"] = encode_chat_messages(self._cache) + state["cache"] = self._cache serialized_thread = await self._thread.serialize() state["thread"] = serialized_thread @@ -486,7 +485,7 @@ class AgentBasedGroupChatOrchestrator(BaseGroupChatOrchestrator): async def on_checkpoint_restore(self, state: dict[str, Any]) -> None: """Restore executor state from checkpoint.""" await super().on_checkpoint_restore(state) - self._cache = decode_chat_messages(state.get("cache", [])) + self._cache = state.get("cache", []) serialized_thread = state.get("thread") if serialized_thread: self._thread = await self._agent.deserialize_thread(serialized_thread) diff --git a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py index 0f23f96dc0..e8f8a81080 100644 --- a/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py +++ b/python/packages/orchestrations/agent_framework_orchestrations/_orchestration_state.py @@ -59,15 +59,14 @@ class OrchestrationState: Returns: Dict with encoded conversation and metadata for persistence """ - from agent_framework._workflows._conversation_state import encode_chat_messages - result: dict[str, Any] = { - "conversation": encode_chat_messages(self.conversation), + "conversation": self.conversation, "round_index": self.round_index, + "orchestrator_name": self.orchestrator_name, "metadata": dict(self.metadata), } if self.task is not None: - result["task"] = encode_chat_messages([self.task])[0] + result["task"] = self.task return result @classmethod @@ -80,16 +79,15 @@ class OrchestrationState: Returns: Restored OrchestrationState instance """ - from agent_framework._workflows._conversation_state import decode_chat_messages - task = None if "task" in data: - decoded_tasks = decode_chat_messages([data["task"]]) + decoded_tasks = [data["task"]] task = decoded_tasks[0] if decoded_tasks else None return cls( - conversation=decode_chat_messages(data.get("conversation", [])), + conversation=data.get("conversation", []), round_index=data.get("round_index", 0), + orchestrator_name=data.get("orchestrator_name", ""), metadata=dict(data.get("metadata", {})), task=task, ) diff --git a/python/packages/orchestrations/tests/test_concurrent.py b/python/packages/orchestrations/tests/test_concurrent.py index 55100af4c3..8712aae3fd 100644 --- a/python/packages/orchestrations/tests/test_concurrent.py +++ b/python/packages/orchestrations/tests/test_concurrent.py @@ -224,13 +224,10 @@ async def test_concurrent_checkpoint_resume_round_trip() -> None: assert baseline_output is not None - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert checkpoints checkpoints.sort(key=lambda cp: cp.timestamp) - resume_checkpoint = next( - (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"), - checkpoints[-1], - ) + resume_checkpoint = checkpoints[1] resumed_participants = ( _FakeAgentExec("agentA", "Alpha"), @@ -270,14 +267,13 @@ async def test_concurrent_checkpoint_runtime_only() -> None: assert baseline_output is not None - checkpoints = await storage.list_checkpoints() - assert checkpoints - checkpoints.sort(key=lambda cp: cp.timestamp) - - resume_checkpoint = next( - (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"), - checkpoints[-1], + checkpoints = await storage.list_checkpoints(workflow_name=wf.name) + assert len(checkpoints) >= 2, ( + "Expected at least 2 checkpoints. The first one is after the start executor, " + "and the second one is after the first round of agent executions." ) + checkpoints.sort(key=lambda cp: cp.timestamp) + resume_checkpoint = checkpoints[1] resumed_agents = [_FakeAgentExec(id="agent1", reply_text="A1"), _FakeAgentExec(id="agent2", reply_text="A2")] wf_resume = ConcurrentBuilder(participants=resumed_agents).build() @@ -320,8 +316,8 @@ async def test_concurrent_checkpoint_runtime_overrides_buildtime() -> None: assert baseline_output is not None - buildtime_checkpoints = await buildtime_storage.list_checkpoints() - runtime_checkpoints = await runtime_storage.list_checkpoints() + buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name) + runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name) assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" diff --git a/python/packages/orchestrations/tests/test_group_chat.py b/python/packages/orchestrations/tests/test_group_chat.py index 9eb94b19d4..6544b681a0 100644 --- a/python/packages/orchestrations/tests/test_group_chat.py +++ b/python/packages/orchestrations/tests/test_group_chat.py @@ -620,7 +620,7 @@ async def test_group_chat_checkpoint_runtime_only() -> None: assert baseline_output is not None - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints" @@ -656,8 +656,8 @@ async def test_group_chat_checkpoint_runtime_overrides_buildtime() -> None: assert baseline_output is not None - buildtime_checkpoints = await buildtime_storage.list_checkpoints() - runtime_checkpoints = await runtime_storage.list_checkpoints() + buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name) + runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name) assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" diff --git a/python/packages/orchestrations/tests/test_magentic.py b/python/packages/orchestrations/tests/test_magentic.py index b24284f9c3..17b6957205 100644 --- a/python/packages/orchestrations/tests/test_magentic.py +++ b/python/packages/orchestrations/tests/test_magentic.py @@ -362,7 +362,7 @@ async def test_magentic_checkpoint_resume_round_trip(): assert req_event is not None assert isinstance(req_event.data, MagenticPlanReviewRequest) - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert checkpoints checkpoints.sort(key=lambda cp: cp.timestamp) resume_checkpoint = checkpoints[-1] @@ -605,8 +605,9 @@ async def test_agent_executor_invoke_with_assistants_client_messages(): async def _collect_checkpoints( storage: InMemoryCheckpointStorage, + workflow_name: str, ) -> list[WorkflowCheckpoint]: - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=workflow_name) assert checkpoints checkpoints.sort(key=lambda cp: cp.timestamp) return checkpoints @@ -619,12 +620,13 @@ async def test_magentic_checkpoint_resume_inner_loop_superstep(): participants=[StubThreadAgent()], checkpoint_storage=storage, manager=InvokeOnceManager() ).build() - async for event in workflow.run("inner-loop task", stream=True): - if event.type == "output": - break + async for _ in workflow.run("inner-loop task", stream=True): + continue - checkpoints = await _collect_checkpoints(storage) - inner_loop_checkpoint = next(cp for cp in checkpoints if cp.metadata.get("superstep") == 1) # type: ignore[reportUnknownMemberType] + checkpoints = await _collect_checkpoints(storage, workflow.name) + # The first checkpoint is after the manager has run. + # The second checkpoint is after the participant has run. + inner_loop_checkpoint = checkpoints[1] resumed = MagenticBuilder( participants=[StubThreadAgent()], checkpoint_storage=storage, manager=InvokeOnceManager() @@ -651,7 +653,7 @@ async def test_magentic_checkpoint_resume_from_saved_state(): if event.type == "output": break - checkpoints = await _collect_checkpoints(storage) + checkpoints = await _collect_checkpoints(storage, workflow.name) # Verify we can resume from the last saved checkpoint resumed_state = checkpoints[-1] # Use the last checkpoint @@ -688,7 +690,7 @@ async def test_magentic_checkpoint_resume_rejects_participant_renames(): assert req_event is not None assert isinstance(req_event.data, MagenticPlanReviewRequest) - checkpoints = await _collect_checkpoints(storage) + checkpoints = await _collect_checkpoints(storage, workflow.name) target_checkpoint = checkpoints[-1] renamed_workflow = MagenticBuilder( @@ -772,7 +774,7 @@ async def test_magentic_checkpoint_runtime_only() -> None: assert baseline_output is not None - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert len(checkpoints) > 0, "Runtime-only checkpointing should have created checkpoints" @@ -806,8 +808,8 @@ async def test_magentic_checkpoint_runtime_overrides_buildtime() -> None: assert baseline_output is not None - buildtime_checkpoints = await buildtime_storage.list_checkpoints() - runtime_checkpoints = await runtime_storage.list_checkpoints() + buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name) + runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name) assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" @@ -856,13 +858,13 @@ async def test_magentic_checkpoint_restore_no_duplicate_history(): break # Get checkpoint - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert len(checkpoints) > 0, "Should have created checkpoints" latest_checkpoint = checkpoints[-1] # Load checkpoint and verify no duplicates in state - checkpoint_data = await storage.load_checkpoint(latest_checkpoint.checkpoint_id) + checkpoint_data = await storage.load(latest_checkpoint.checkpoint_id) assert checkpoint_data is not None # Check the magentic_context in the checkpoint diff --git a/python/packages/orchestrations/tests/test_sequential.py b/python/packages/orchestrations/tests/test_sequential.py index 880e33761d..04a4ae4141 100644 --- a/python/packages/orchestrations/tests/test_sequential.py +++ b/python/packages/orchestrations/tests/test_sequential.py @@ -146,14 +146,10 @@ async def test_sequential_checkpoint_resume_round_trip() -> None: assert baseline_output is not None - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert checkpoints checkpoints.sort(key=lambda cp: cp.timestamp) - - resume_checkpoint = next( - (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"), - checkpoints[-1], - ) + resume_checkpoint = checkpoints[0] resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf_resume = SequentialBuilder(participants=list(resumed_agents), checkpoint_storage=storage).build() @@ -189,14 +185,10 @@ async def test_sequential_checkpoint_runtime_only() -> None: assert baseline_output is not None - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=wf.name) assert checkpoints checkpoints.sort(key=lambda cp: cp.timestamp) - - resume_checkpoint = next( - (cp for cp in checkpoints if (cp.metadata or {}).get("checkpoint_type") == "superstep"), - checkpoints[-1], - ) + resume_checkpoint = checkpoints[0] resumed_agents = (_EchoAgent(id="agent1", name="A1"), _EchoAgent(id="agent2", name="A2")) wf_resume = SequentialBuilder(participants=list(resumed_agents)).build() @@ -240,8 +232,8 @@ async def test_sequential_checkpoint_runtime_overrides_buildtime() -> None: assert baseline_output is not None - buildtime_checkpoints = await buildtime_storage.list_checkpoints() - runtime_checkpoints = await runtime_storage.list_checkpoints() + buildtime_checkpoints = await buildtime_storage.list_checkpoints(workflow_name=wf.name) + runtime_checkpoints = await runtime_storage.list_checkpoints(workflow_name=wf.name) assert len(runtime_checkpoints) > 0, "Runtime storage should have checkpoints" assert len(buildtime_checkpoints) == 0, "Build-time storage should have no checkpoints when overridden" diff --git a/python/packages/purview/agent_framework_purview/__init__.py b/python/packages/purview/agent_framework_purview/__init__.py index 79722f1b50..44b5312d1c 100644 --- a/python/packages/purview/agent_framework_purview/__init__.py +++ b/python/packages/purview/agent_framework_purview/__init__.py @@ -9,7 +9,7 @@ from ._exceptions import ( PurviewServiceError, ) from ._middleware import PurviewChatPolicyMiddleware, PurviewPolicyMiddleware -from ._settings import PurviewAppLocation, PurviewLocationType, PurviewSettings +from ._settings import PurviewAppLocation, PurviewLocationType, PurviewSettings, get_purview_scopes __all__ = [ "CacheProvider", @@ -23,4 +23,5 @@ __all__ = [ "PurviewRequestError", "PurviewServiceError", "PurviewSettings", + "get_purview_scopes", ] diff --git a/python/packages/purview/agent_framework_purview/_client.py b/python/packages/purview/agent_framework_purview/_client.py index 3eda072ca3..b2b07b2637 100644 --- a/python/packages/purview/agent_framework_purview/_client.py +++ b/python/packages/purview/agent_framework_purview/_client.py @@ -31,7 +31,7 @@ from ._models import ( ProtectionScopesRequest, ProtectionScopesResponse, ) -from ._settings import PurviewSettings +from ._settings import PurviewSettings, get_purview_scopes logger = get_logger("agent_framework.purview") @@ -52,7 +52,7 @@ class PurviewClient: ): self._credential: TokenCredential | AsyncTokenCredential = credential self._settings = settings - self._graph_uri = settings.graph_base_uri.rstrip("/") + self._graph_uri = (settings.get("graph_base_uri") or "https://graph.microsoft.com/v1.0/").rstrip("/") self._timeout = timeout self._client = httpx.AsyncClient(timeout=timeout) @@ -61,7 +61,7 @@ class PurviewClient: async def _get_token(self, *, tenant_id: str | None = None) -> str: """Acquire an access token using either async or sync credential.""" - scopes = self._settings.get_scopes() + scopes = get_purview_scopes(self._settings) cred = self._credential token = cred.get_token(*scopes, tenant_id=tenant_id) token = await token if inspect.isawaitable(token) else token @@ -167,7 +167,7 @@ class PurviewClient: if resp.status_code in (401, 403): raise PurviewAuthenticationError(f"Auth failure {resp.status_code}: {resp.text}") if resp.status_code == 402: - if self._settings.ignore_payment_required: + if self._settings.get("ignore_payment_required", False): return response_type() # type: ignore[call-arg, no-any-return] raise PurviewPaymentRequiredError(f"Payment required {resp.status_code}: {resp.text}") if resp.status_code == 429: diff --git a/python/packages/purview/agent_framework_purview/_middleware.py b/python/packages/purview/agent_framework_purview/_middleware.py index 2da8de84ee..70f3b2892f 100644 --- a/python/packages/purview/agent_framework_purview/_middleware.py +++ b/python/packages/purview/agent_framework_purview/_middleware.py @@ -78,18 +78,22 @@ class PurviewPolicyMiddleware(AgentMiddleware): from agent_framework import AgentResponse, Message context.result = AgentResponse( - messages=[Message(role="system", text=self._settings.blocked_prompt_message)] + messages=[ + Message( + role="system", text=self._settings.get("blocked_prompt_message", "Prompt blocked by policy") + ) + ] ) raise MiddlewareTermination except MiddlewareTermination: raise except PurviewPaymentRequiredError as ex: logger.error(f"Purview payment required error in policy pre-check: {ex}") - if not self._settings.ignore_payment_required: + if not self._settings.get("ignore_payment_required", False): raise except Exception as ex: logger.error(f"Error in Purview policy pre-check: {ex}") - if not self._settings.ignore_exceptions: + if not self._settings.get("ignore_exceptions", False): raise await call_next() @@ -111,18 +115,23 @@ class PurviewPolicyMiddleware(AgentMiddleware): from agent_framework import AgentResponse, Message context.result = AgentResponse( - messages=[Message(role="system", text=self._settings.blocked_response_message)] + messages=[ + Message( + role="system", + text=self._settings.get("blocked_response_message", "Response blocked by policy"), + ) + ] ) else: # Streaming responses are not supported for post-checks logger.debug("Streaming responses are not supported for Purview policy post-checks") except PurviewPaymentRequiredError as ex: logger.error(f"Purview payment required error in policy post-check: {ex}") - if not self._settings.ignore_payment_required: + if not self._settings.get("ignore_payment_required", False): raise except Exception as ex: logger.error(f"Error in Purview policy post-check: {ex}") - if not self._settings.ignore_exceptions: + if not self._settings.get("ignore_exceptions", False): raise @@ -173,18 +182,20 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): if should_block_prompt: from agent_framework import ChatResponse, Message - blocked_message = Message(role="system", text=self._settings.blocked_prompt_message) + blocked_message = Message( + role="system", text=self._settings.get("blocked_prompt_message", "Prompt blocked by policy") + ) context.result = ChatResponse(messages=[blocked_message]) raise MiddlewareTermination except MiddlewareTermination: raise except PurviewPaymentRequiredError as ex: logger.error(f"Purview payment required error in policy pre-check: {ex}") - if not self._settings.ignore_payment_required: + if not self._settings.get("ignore_payment_required", False): raise except Exception as ex: logger.error(f"Error in Purview policy pre-check: {ex}") - if not self._settings.ignore_exceptions: + if not self._settings.get("ignore_exceptions", False): raise await call_next() @@ -205,15 +216,18 @@ class PurviewChatPolicyMiddleware(ChatMiddleware): if should_block_response: from agent_framework import ChatResponse, Message - blocked_message = Message(role="system", text=self._settings.blocked_response_message) + blocked_message = Message( + role="system", + text=self._settings.get("blocked_response_message", "Response blocked by policy"), + ) context.result = ChatResponse(messages=[blocked_message]) else: logger.debug("Streaming responses are not supported for Purview policy post-checks") except PurviewPaymentRequiredError as ex: logger.error(f"Purview payment required error in policy post-check: {ex}") - if not self._settings.ignore_payment_required: + if not self._settings.get("ignore_payment_required", False): raise except Exception as ex: logger.error(f"Error in Purview policy post-check: {ex}") - if not self._settings.ignore_exceptions: + if not self._settings.get("ignore_exceptions", False): raise diff --git a/python/packages/purview/agent_framework_purview/_processor.py b/python/packages/purview/agent_framework_purview/_processor.py index 5525897a9e..bc8cd045c0 100644 --- a/python/packages/purview/agent_framework_purview/_processor.py +++ b/python/packages/purview/agent_framework_purview/_processor.py @@ -57,8 +57,11 @@ class ScopedContentProcessor: def __init__(self, client: PurviewClient, settings: PurviewSettings, cache_provider: CacheProvider | None = None): self._client = client self._settings = settings + cache_ttl = settings.get("cache_ttl_seconds") + max_cache = settings.get("max_cache_size_bytes") self._cache: CacheProvider = cache_provider or InMemoryCacheProvider( - default_ttl_seconds=settings.cache_ttl_seconds, max_size_bytes=settings.max_cache_size_bytes + default_ttl_seconds=cache_ttl if cache_ttl is not None else 14400, + max_size_bytes=max_cache if max_cache is not None else 200 * 1024 * 1024, ) self._background_tasks: set[asyncio.Task[Any]] = set() @@ -116,10 +119,10 @@ class ScopedContentProcessor: results: list[ProcessContentRequest] = [] token_info = None - if not (self._settings.tenant_id and self._settings.purview_app_location): - token_info = await self._client.get_user_info_from_token(tenant_id=self._settings.tenant_id) + if not (self._settings.get("tenant_id") and self._settings.get("purview_app_location")): + token_info = await self._client.get_user_info_from_token(tenant_id=self._settings.get("tenant_id")) - tenant_id = (token_info or {}).get("tenant_id") or self._settings.tenant_id + tenant_id = (token_info or {}).get("tenant_id") or self._settings.get("tenant_id") if not tenant_id or not _is_valid_guid(tenant_id): raise ValueError("Tenant id required or must be inferable from credential") @@ -159,10 +162,11 @@ class ScopedContentProcessor: ) activity_meta = ActivityMetadata(activity=activity) - if self._settings.purview_app_location: + purview_app_location = self._settings.get("purview_app_location") + if purview_app_location: policy_location = PolicyLocation( - data_type=self._settings.purview_app_location.get_policy_location()["@odata.type"], - value=self._settings.purview_app_location.location_value, + data_type=purview_app_location.get_policy_location()["@odata.type"], + value=purview_app_location.location_value, ) elif token_info and token_info.get("client_id"): policy_location = PolicyLocation( @@ -172,13 +176,14 @@ class ScopedContentProcessor: else: raise ValueError("App location not provided or inferable") - app_version = self._settings.app_version or "Unknown" protected_app = ProtectedAppMetadata( - name=self._settings.app_name, - version=app_version, + name=self._settings["app_name"], + version=self._settings.get("app_version", "Unknown"), application_location=policy_location, ) - integrated_app = IntegratedAppMetadata(name=self._settings.app_name, version=app_version) + integrated_app = IntegratedAppMetadata( + name=self._settings["app_name"], version=self._settings.get("app_version", "Unknown") + ) device_meta = DeviceMetadata( operating_system_specifications=OperatingSystemSpecifications( operating_system_platform="Unknown", operating_system_version="Unknown" @@ -229,11 +234,13 @@ class ScopedContentProcessor: ps_resp = cached_ps_resp else: try: + ttl = self._settings.get("cache_ttl_seconds") + ttl_seconds = ttl if ttl is not None else 14400 ps_resp = await self._client.get_protection_scopes(ps_req) - await self._cache.set(cache_key, ps_resp, ttl_seconds=self._settings.cache_ttl_seconds) + await self._cache.set(cache_key, ps_resp, ttl_seconds=ttl_seconds) except PurviewPaymentRequiredError as ex: # Cache the exception at tenant level so all subsequent requests for this tenant fail fast - await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=self._settings.cache_ttl_seconds) + await self._cache.set(tenant_payment_cache_key, ex, ttl_seconds=ttl_seconds) raise if ps_resp.scope_identifier: diff --git a/python/packages/purview/agent_framework_purview/_settings.py b/python/packages/purview/agent_framework_purview/_settings.py index 3710d9de52..9581d041fb 100644 --- a/python/packages/purview/agent_framework_purview/_settings.py +++ b/python/packages/purview/agent_framework_purview/_settings.py @@ -1,10 +1,14 @@ # Copyright (c) Microsoft. All rights reserved. +import sys from enum import Enum -from agent_framework._pydantic import AFBaseSettings -from pydantic import BaseModel, Field -from pydantic_settings import SettingsConfigDict +from pydantic import BaseModel + +if sys.version_info >= (3, 11): + from typing import TypedDict # pragma: no cover +else: + from typing_extensions import TypedDict # type: ignore # pragma: no cover class PurviewLocationType(str, Enum): @@ -18,8 +22,8 @@ class PurviewLocationType(str, Enum): class PurviewAppLocation(BaseModel): """Identifier representing the app's location for Purview policy evaluation.""" - location_type: PurviewLocationType = Field(..., description="The location type.") - location_value: str = Field(..., description="The location value.") + location_type: PurviewLocationType + location_value: str def get_policy_location(self) -> dict[str, str]: ns = "microsoft.graph" @@ -34,8 +38,8 @@ class PurviewAppLocation(BaseModel): return {"@odata.type": dt, "value": self.location_value} -class PurviewSettings(AFBaseSettings): - """Settings for Purview integration. +class PurviewSettings(TypedDict, total=False): + """Settings for Purview integration mirroring .NET PurviewSettings. Attributes: app_name: Public app name. @@ -51,40 +55,30 @@ class PurviewSettings(AFBaseSettings): max_cache_size_bytes: Maximum cache size in bytes (default 200MB). """ - app_name: str = Field(...) - app_version: str | None = Field(default=None) - tenant_id: str | None = Field(default=None) - purview_app_location: PurviewAppLocation | None = Field(default=None) - graph_base_uri: str = Field(default="https://graph.microsoft.com/v1.0/") - blocked_prompt_message: str = Field( - default="Prompt blocked by policy", - description="Message to return when a prompt is blocked by policy.", - ) - blocked_response_message: str = Field( - default="Response blocked by policy", - description="Message to return when a response is blocked by policy.", - ) - ignore_exceptions: bool = Field( - default=False, - description="If True, all Purview exceptions will be logged but not thrown in middleware.", - ) - ignore_payment_required: bool = Field( - default=False, - description="If True, 402 payment required errors will be logged but not thrown.", - ) - cache_ttl_seconds: int = Field( - default=14400, - description="Time to live for cache entries in seconds (default 14400 = 4 hours).", - ) - max_cache_size_bytes: int = Field( - default=200 * 1024 * 1024, - description="Maximum cache size in bytes (default 200MB).", - ) + app_name: str | None + app_version: str | None + tenant_id: str | None + purview_app_location: PurviewAppLocation | None + graph_base_uri: str | None + blocked_prompt_message: str | None + blocked_response_message: str | None + ignore_exceptions: bool | None + ignore_payment_required: bool | None + cache_ttl_seconds: int | None + max_cache_size_bytes: int | None - model_config = SettingsConfigDict(populate_by_name=True, validate_assignment=True) - def get_scopes(self) -> list[str]: - from urllib.parse import urlparse +def get_purview_scopes(settings: PurviewSettings) -> list[str]: + """Get the OAuth scopes for the Purview Graph API. - host = urlparse(self.graph_base_uri).hostname or "graph.microsoft.com" - return [f"https://{host}/.default"] + Args: + settings: The Purview settings containing graph_base_uri. + + Returns: + A list of OAuth scope strings. + """ + from urllib.parse import urlparse + + graph_base_uri = settings.get("graph_base_uri", "https://graph.microsoft.com/v1.0/") + host = urlparse(str(graph_base_uri)).hostname or "graph.microsoft.com" + return [f"https://{host}/.default"] diff --git a/python/packages/purview/tests/purview/test_chat_middleware.py b/python/packages/purview/tests/purview/test_chat_middleware.py index bc9be01e1f..e740cdabc6 100644 --- a/python/packages/purview/tests/purview/test_chat_middleware.py +++ b/python/packages/purview/tests/purview/test_chat_middleware.py @@ -125,7 +125,7 @@ class TestPurviewChatPolicyMiddleware: ) -> None: """Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True.""" # Set ignore_exceptions to True to test exception suppression - middleware._settings.ignore_exceptions = True + middleware._settings["ignore_exceptions"] = True call_count = 0 diff --git a/python/packages/purview/tests/purview/test_middleware.py b/python/packages/purview/tests/purview/test_middleware.py index 98dafab1e1..451aaf9df7 100644 --- a/python/packages/purview/tests/purview/test_middleware.py +++ b/python/packages/purview/tests/purview/test_middleware.py @@ -119,7 +119,7 @@ class TestPurviewPolicyMiddleware: ) -> None: """Test middleware handles result that doesn't have messages attribute.""" # Set ignore_exceptions to True so AttributeError is caught and logged - middleware._settings.ignore_exceptions = True + middleware._settings["ignore_exceptions"] = True context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) @@ -216,7 +216,7 @@ class TestPurviewPolicyMiddleware: self, middleware: PurviewPolicyMiddleware, mock_agent: MagicMock ) -> None: """Test that post-check exceptions are propagated when ignore_exceptions=False.""" - middleware._settings.ignore_exceptions = False + middleware._settings["ignore_exceptions"] = False context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Hello")]) @@ -242,7 +242,7 @@ class TestPurviewPolicyMiddleware: ) -> None: """Test that exceptions in pre-check are logged but don't stop processing when ignore_exceptions=True.""" # Set ignore_exceptions to True - middleware._settings.ignore_exceptions = True + middleware._settings["ignore_exceptions"] = True context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) @@ -265,7 +265,7 @@ class TestPurviewPolicyMiddleware: ) -> None: """Test that exceptions in post-check are logged but don't affect result when ignore_exceptions=True.""" # Set ignore_exceptions to True - middleware._settings.ignore_exceptions = True + middleware._settings["ignore_exceptions"] = True context = AgentContext(agent=mock_agent, messages=[Message(role="user", text="Test")]) diff --git a/python/packages/purview/tests/purview/test_processor.py b/python/packages/purview/tests/purview/test_processor.py index ab96999921..7e70072508 100644 --- a/python/packages/purview/tests/purview/test_processor.py +++ b/python/packages/purview/tests/purview/test_processor.py @@ -636,7 +636,6 @@ class TestScopedContentProcessorCaching: return PurviewSettings( app_name="Test App", tenant_id="12345678-1234-1234-1234-123456789012", - default_user_id="12345678-1234-1234-1234-123456789012", purview_app_location=location, ) diff --git a/python/packages/purview/tests/purview/test_purview_client.py b/python/packages/purview/tests/purview/test_purview_client.py index b740b3f09c..a61724d3f0 100644 --- a/python/packages/purview/tests/purview/test_purview_client.py +++ b/python/packages/purview/tests/purview/test_purview_client.py @@ -47,7 +47,7 @@ class TestPurviewClient: @pytest.fixture def settings(self) -> PurviewSettings: """Create test settings.""" - return PurviewSettings(app_name="Test App", tenant_id="test-tenant", default_user_id="test-user") + return PurviewSettings(app_name="Test App", tenant_id="test-tenant") @pytest.fixture async def client( diff --git a/python/packages/purview/tests/purview/test_settings.py b/python/packages/purview/tests/purview/test_settings.py index 9abc27aed1..42e03a2be3 100644 --- a/python/packages/purview/tests/purview/test_settings.py +++ b/python/packages/purview/tests/purview/test_settings.py @@ -4,7 +4,7 @@ import pytest -from agent_framework_purview import PurviewAppLocation, PurviewLocationType, PurviewSettings +from agent_framework_purview import PurviewAppLocation, PurviewLocationType, PurviewSettings, get_purview_scopes class TestPurviewSettings: @@ -14,10 +14,10 @@ class TestPurviewSettings: """Test PurviewSettings with default values.""" settings = PurviewSettings(app_name="Test App") - assert settings.app_name == "Test App" - assert settings.graph_base_uri == "https://graph.microsoft.com/v1.0/" - assert settings.tenant_id is None - assert settings.purview_app_location is None + assert settings["app_name"] == "Test App" + assert settings.get("graph_base_uri") is None + assert settings.get("tenant_id") is None + assert settings.get("purview_app_location") is None def test_settings_with_custom_values(self) -> None: """Test PurviewSettings with custom values.""" @@ -30,9 +30,9 @@ class TestPurviewSettings: purview_app_location=app_location, ) - assert settings.graph_base_uri == "https://graph.microsoft-ppe.com" - assert settings.tenant_id == "test-tenant-id" - assert settings.purview_app_location.location_value == "app-123" + assert settings["graph_base_uri"] == "https://graph.microsoft-ppe.com" + assert settings["tenant_id"] == "test-tenant-id" + assert settings["purview_app_location"].location_value == "app-123" @pytest.mark.parametrize( "graph_uri,expected_scope", @@ -44,7 +44,7 @@ class TestPurviewSettings: def test_get_scopes(self, graph_uri: str, expected_scope: str) -> None: """Test get_scopes returns correct scope for different URIs.""" settings = PurviewSettings(app_name="Test App", graph_base_uri=graph_uri) - scopes = settings.get_scopes() + scopes = get_purview_scopes(settings) assert len(scopes) == 1 assert expected_scope in scopes diff --git a/python/pyproject.toml b/python/pyproject.toml index 5c4fdd1788..a60f52aeee 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -241,6 +241,7 @@ pytest --import-mode=importlib --cov=agent_framework_ag_ui --cov=agent_framework_anthropic --cov=agent_framework_azure_ai +--cov=agent_framework_azure_ai_search --cov=agent_framework_azurefunctions --cov=agent_framework_chatkit --cov=agent_framework_copilotstudio @@ -248,6 +249,7 @@ pytest --import-mode=importlib --cov=agent_framework_purview --cov=agent_framework_redis --cov=agent_framework_orchestrations +--cov=agent_framework_declarative --cov-config=pyproject.toml --cov-report=term-missing:skip-covered --ignore-glob=packages/lab/** diff --git a/python/samples/getting_started/orchestrations/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/getting_started/orchestrations/handoff_with_tool_approval_checkpoint_resume.py new file mode 100644 index 0000000000..ce377b654d --- /dev/null +++ b/python/samples/getting_started/orchestrations/handoff_with_tool_approval_checkpoint_resume.py @@ -0,0 +1,230 @@ +# Copyright (c) Microsoft. All rights reserved. + +import asyncio +import json +from pathlib import Path +from typing import Any + +from agent_framework import ( + Agent, + Content, + FileCheckpointStorage, + Workflow, + tool, +) +from agent_framework.azure import AzureOpenAIChatClient +from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder +from azure.identity import AzureCliCredential + +""" +Sample: Handoff Workflow with Tool Approvals + Checkpoint Resume + +Demonstrates resuming a handoff workflow from a checkpoint while handling both +HandoffAgentUserRequest prompts and function approval request Content for tool calls +(e.g., submit_refund). + +Scenario: +1. User starts a conversation with the workflow. +2. Agents may emit user input requests or tool approval requests. +3. Workflow writes a checkpoint capturing pending requests and pauses. +4. Process can exit/restart. +5. On resume: Restore checkpoint, inspect pending requests, then provide responses. +6. Workflow continues from the saved state. + +Pattern: +- workflow.run(checkpoint_id=..., stream=True) to restore checkpoint and discover pending requests. +- workflow.run(stream=True, responses=responses) to supply human replies and approvals. + (Two steps are needed here because the sample must inspect request types before building responses. + When response payloads are already known, use the single-call form: + workflow.run(stream=True, checkpoint_id=..., responses=responses).) + +Prerequisites: +- Azure CLI authentication (az login). +- Environment variables configured for AzureOpenAIChatClient. +""" + +CHECKPOINT_DIR = Path(__file__).parent / "tmp" / "handoff_checkpoints" +CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True) + + +@tool(approval_mode="always_require") +def submit_refund(refund_description: str, amount: str, order_id: str) -> str: + """Capture a refund request for manual review before processing.""" + return f"refund recorded for order {order_id} (amount: {amount}) with details: {refund_description}" + + +def create_agents(client: AzureOpenAIChatClient) -> tuple[Agent, Agent, Agent]: + """Create a simple handoff scenario: triage, refund, and order specialists.""" + + triage = client.as_agent( + name="triage_agent", + instructions=( + "You are a customer service triage agent. Listen to customer issues and determine " + "if they need refund help or order tracking. Use handoff_to_refund_agent or " + "handoff_to_order_agent to transfer them." + ), + ) + + refund = client.as_agent( + name="refund_agent", + instructions=( + "You are a refund specialist. Help customers with refund requests. " + "Be empathetic and ask for order numbers if not provided. " + "When the user confirms they want a refund and supplies order details, call submit_refund " + "to record the request before continuing." + ), + tools=[submit_refund], + ) + + order = client.as_agent( + name="order_agent", + instructions=( + "You are an order tracking specialist. Help customers track their orders. " + "Ask for order numbers and provide shipping updates." + ), + ) + + return triage, refund, order + + +def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow: + """Build the handoff workflow with checkpointing enabled.""" + + client = AzureOpenAIChatClient(credential=AzureCliCredential()) + triage, refund, order = create_agents(client) + + # checkpoint_storage: Enable checkpointing for resume + # termination_condition: Terminate after 5 user messages for this demo + return ( + HandoffBuilder( + name="checkpoint_handoff_demo", + participants=[triage, refund, order], + checkpoint_storage=checkpoint_storage, + termination_condition=lambda conv: sum(1 for msg in conv if msg.role == "user") >= 5, + ) + .with_start_agent(triage) + .build() + ) + + +def print_handoff_agent_user_request(request: HandoffAgentUserRequest, request_id: str) -> None: + """Log pending handoff request details for debugging.""" + print(f"\n{'=' * 60}") + print("User input needed") + print(f"Request ID: {request_id}") + print(f"Awaiting agent: {request.agent_response.agent_id}") + + response = request.agent_response + if not response.messages: + print("(No agent messages)") + return + + for message in response.messages: + if not message.text: + continue + speaker = message.author_name or message.role + print(f"{speaker}: {message.text}") + + print(f"{'=' * 60}\n") + + +def print_function_approval_request(request: Content, request_id: str) -> None: + """Log pending tool approval details for debugging.""" + args = request.function_call.parse_arguments() or {} # type: ignore + print(f"\n{'=' * 60}") + print("Tool approval required") + print(f"Request ID: {request_id}") + print(f"Function: {request.function_call.name}") # type: ignore + print(f"Arguments:\n{json.dumps(args, indent=2)}") + print(f"{'=' * 60}\n") + + +async def main() -> None: + """ + Demonstrate the checkpoint-based pause/resume pattern for handoff workflows. + + This sample shows: + 1. Starting a workflow and getting a HandoffAgentUserRequest + 2. Pausing (checkpoint is saved automatically) + 3. Resuming from checkpoint with a user response or tool approval + 4. Continuing the conversation until completion + """ + # Clean up old checkpoints + for file in CHECKPOINT_DIR.glob("*.json"): + file.unlink() + for file in CHECKPOINT_DIR.glob("*.json.tmp"): + file.unlink() + + storage = FileCheckpointStorage(storage_path=CHECKPOINT_DIR) + workflow = create_workflow(checkpoint_storage=storage) + + # Scripted human input for demo purposes + handoff_responses = [ + ( + "The headphones in order 12345 arrived cracked. " + "Please submit the refund for $89.99 and send a replacement to my original address." + ), + "Yes, that covers the damage and refund request.", + "That's everything I needed for the refund.", + "Thanks for handling the refund.", + ] + + print("=" * 60) + print("HANDOFF WORKFLOW CHECKPOINT DEMO") + print("=" * 60) + + # Scenario: User needs help with a damaged order + initial_request = "Hi, my order 12345 arrived damaged. I need a refund." + + # Phase 1: Initial run - workflow will pause when it needs user input + results = await workflow.run(message=initial_request) + request_events = results.get_request_info_events() + if not request_events: + print("Workflow completed without needing user input") + return + + print("=" * 60) + print("WORKFLOW PAUSED with pending requests") + print("=" * 60) + + # Phase 2: Running until no more user input is needed + # This creates a new workflow instance to simulate a fresh process start, + # but points it to the same checkpoint storage + while request_events: + print("=" * 60) + print("Simulating process restart...") + print("=" * 60) + + workflow = create_workflow(checkpoint_storage=storage) + + responses: dict[str, Any] = {} + for request_event in request_events: + print(f"Pending request ID: {request_event.request_id}, Type: {type(request_event.data)}") + if isinstance(request_event.data, HandoffAgentUserRequest): + print_handoff_agent_user_request(request_event.data, request_event.request_id) + response = handoff_responses.pop(0) + print(f"Responding with: {response}") + responses[request_event.request_id] = HandoffAgentUserRequest.create_response(response) + elif isinstance(request_event.data, Content) and request_event.data.type == "function_approval_request": + print_function_approval_request(request_event.data, request_event.request_id) + print("Approving tool call...") + responses[request_event.request_id] = request_event.data.to_function_approval_response(approved=True) + else: + # This sample only expects HandoffAgentUserRequest and function approval requests + raise ValueError(f"Unsupported request type: {type(request_event.data)}") + + checkpoint = await storage.get_latest(workflow_name=workflow.name) + if not checkpoint: + raise RuntimeError("No checkpoints found.") + checkpoint_id = checkpoint.checkpoint_id + + results = await workflow.run(responses=responses, checkpoint_id=checkpoint_id) + request_events = results.get_request_info_events() + + print("\n" + "=" * 60) + print("DEMO COMPLETE") + print("=" * 60) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/python/samples/getting_started/orchestrations/magentic_checkpoint.py b/python/samples/getting_started/orchestrations/magentic_checkpoint.py index 05437a8601..adce878f0d 100644 --- a/python/samples/getting_started/orchestrations/magentic_checkpoint.py +++ b/python/samples/getting_started/orchestrations/magentic_checkpoint.py @@ -2,6 +2,7 @@ import asyncio import json +from datetime import datetime from pathlib import Path from typing import cast @@ -115,15 +116,11 @@ async def main() -> None: print("No plan review request emitted; nothing to resume.") return - checkpoints = await checkpoint_storage.list_checkpoints(workflow.id) - if not checkpoints: + resume_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow.name) + if not resume_checkpoint: print("No checkpoints persisted.") return - resume_checkpoint = max( - checkpoints, - key=lambda cp: (cp.iteration_count, cp.timestamp), - ) print(f"Using checkpoint {resume_checkpoint.checkpoint_id} at iteration {resume_checkpoint.iteration_count}") # Show that the checkpoint JSON indeed contains the pending plan-review request record. @@ -180,7 +177,7 @@ async def main() -> None: def _pending_message_count(cp: WorkflowCheckpoint) -> int: return sum(len(msg_list) for msg_list in cp.messages.values() if isinstance(msg_list, list)) - all_checkpoints = await checkpoint_storage.list_checkpoints(resume_checkpoint.workflow_id) + all_checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=resume_checkpoint.workflow_name) later_checkpoints_with_messages = [ cp for cp in all_checkpoints @@ -188,10 +185,7 @@ async def main() -> None: ] if later_checkpoints_with_messages: - post_plan_checkpoint = max( - later_checkpoints_with_messages, - key=lambda cp: (cp.iteration_count, cp.timestamp), - ) + post_plan_checkpoint = max(later_checkpoints_with_messages, key=lambda cp: datetime.fromisoformat(cp.timestamp)) else: later_checkpoints = [cp for cp in all_checkpoints if cp.iteration_count > resume_checkpoint.iteration_count] @@ -199,10 +193,7 @@ async def main() -> None: print("\nNo additional checkpoints recorded beyond plan approval; sample complete.") return - post_plan_checkpoint = max( - later_checkpoints, - key=lambda cp: (cp.iteration_count, cp.timestamp), - ) + post_plan_checkpoint = max(later_checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp)) print("\n=== Stage 3: resume from post-plan checkpoint ===") pending_messages = _pending_message_count(post_plan_checkpoint) print( diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py index ec194d0fa3..12cb08a8be 100644 --- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py +++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_human_in_the_loop.py @@ -3,6 +3,7 @@ import asyncio import sys from dataclasses import dataclass +from datetime import datetime from pathlib import Path from typing import Any @@ -25,9 +26,7 @@ from agent_framework import ( Message, Workflow, WorkflowBuilder, - WorkflowCheckpoint, WorkflowContext, - get_checkpoint_summary, handler, response_handler, ) @@ -188,9 +187,7 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow: prepare_brief = BriefPreparer(id="prepare_brief", agent_id="writer") workflow_builder = ( - WorkflowBuilder( - max_iterations=6, start_executor=prepare_brief, checkpoint_storage=checkpoint_storage - ) + WorkflowBuilder(max_iterations=6, start_executor=prepare_brief, checkpoint_storage=checkpoint_storage) .add_edge(prepare_brief, writer) .add_edge(writer, review_gateway) .add_edge(review_gateway, writer) # revisions loop @@ -199,24 +196,6 @@ def create_workflow(checkpoint_storage: FileCheckpointStorage) -> Workflow: return workflow_builder.build() -def render_checkpoint_summary(checkpoints: list["WorkflowCheckpoint"]) -> None: - """Pretty-print saved checkpoints with the new framework summaries.""" - - print("\nCheckpoint summary:") - for summary in [get_checkpoint_summary(cp) for cp in sorted(checkpoints, key=lambda c: c.timestamp)]: - # Compose a single line per checkpoint so the user can scan the output - # and pick the resume point that still has outstanding human work. - line = ( - f"- {summary.checkpoint_id} | timestamp={summary.timestamp} | iter={summary.iteration_count} " - f"| targets={summary.targets} | states={summary.executor_ids}" - ) - if summary.status: - line += f" | status={summary.status}" - if summary.pending_request_info_events: - line += f" | pending_request_id={summary.pending_request_info_events[0].request_id}" - print(line) - - def prompt_for_responses(requests: dict[str, HumanApprovalRequest]) -> dict[str, str]: """Interactive CLI prompt for any live RequestInfo requests.""" @@ -304,16 +283,12 @@ async def main() -> None: result = await run_interactive_session(workflow, initial_message=brief) print(f"Workflow completed with: {result}") - checkpoints = await storage.list_checkpoints() + checkpoints = await storage.list_checkpoints(workflow_name=workflow.name) if not checkpoints: print("No checkpoints recorded.") return - # Show the user what is available before we prompt for the index. The - # summary helper keeps this output consistent with other tooling. - render_checkpoint_summary(checkpoints) - - sorted_cps = sorted(checkpoints, key=lambda c: c.timestamp) + sorted_cps = sorted(checkpoints, key=lambda cp: datetime.fromisoformat(cp.timestamp)) print("\nAvailable checkpoints:") for idx, cp in enumerate(sorted_cps): print(f" [{idx}] id={cp.checkpoint_id} iter={cp.iteration_count}") @@ -337,10 +312,6 @@ async def main() -> None: return chosen = sorted_cps[idx] - summary = get_checkpoint_summary(chosen) - if summary.status == "completed": - print("Selected checkpoint already reflects a completed workflow; nothing to resume.") - return new_workflow = create_workflow(checkpoint_storage=storage) # Resume with a fresh workflow instance. The checkpoint carries the diff --git a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py index 22a8423cba..572dd4f0ee 100644 --- a/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py +++ b/python/samples/getting_started/workflows/checkpoint/checkpoint_with_resume.py @@ -140,10 +140,9 @@ async def main(): break # Find the latest checkpoint to resume from - all_checkpoints = await checkpoint_storage.list_checkpoints() - if not all_checkpoints: + latest_checkpoint = await checkpoint_storage.get_latest(workflow_name=workflow.name) + if not latest_checkpoint: raise RuntimeError("No checkpoints available to resume from.") - latest_checkpoint = all_checkpoints[-1] print( f"Checkpoint {latest_checkpoint.checkpoint_id}: " f"(iter={latest_checkpoint.iteration_count}, messages={latest_checkpoint.messages})" diff --git a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py b/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py deleted file mode 100644 index f39c997457..0000000000 --- a/python/samples/getting_started/workflows/checkpoint/handoff_with_tool_approval_checkpoint_resume.py +++ /dev/null @@ -1,405 +0,0 @@ -# Copyright (c) Microsoft. All rights reserved. - -import asyncio -import json -import logging -from pathlib import Path -from typing import cast - -from agent_framework import ( - Agent, - AgentResponse, - Content, - FileCheckpointStorage, - Message, - Workflow, - WorkflowEvent, - tool, -) -from agent_framework.azure import AzureOpenAIChatClient -from agent_framework.orchestrations import HandoffAgentUserRequest, HandoffBuilder -from azure.identity import AzureCliCredential - -""" -Sample: Handoff Workflow with Tool Approvals + Checkpoint Resume - -Demonstrates resuming a handoff workflow from a checkpoint while handling both -HandoffAgentUserRequest prompts and function approval request Content for tool calls -(e.g., submit_refund). - -Scenario: -1. User starts a conversation with the workflow. -2. Agents may emit user input requests or tool approval requests. -3. Workflow writes a checkpoint capturing pending requests and pauses. -4. Process can exit/restart. -5. On resume: Restore checkpoint, inspect pending requests, then provide responses. -6. Workflow continues from the saved state. - -Pattern: -- workflow.run(checkpoint_id=..., stream=True) to restore checkpoint and discover pending requests. -- workflow.run(stream=True, responses=responses) to supply human replies and approvals. - (Two steps are needed here because the sample must inspect request types before building responses. - When response payloads are already known, use the single-call form: - workflow.run(stream=True, checkpoint_id=..., responses=responses).) - -Prerequisites: -- Azure CLI authentication (az login). -- Environment variables configured for AzureOpenAIChatClient. -""" - -CHECKPOINT_DIR = Path(__file__).parent / "tmp" / "handoff_checkpoints" -CHECKPOINT_DIR.mkdir(parents=True, exist_ok=True) - - -@tool(approval_mode="always_require") -def submit_refund(refund_description: str, amount: str, order_id: str) -> str: - """Capture a refund request for manual review before processing.""" - return f"refund recorded for order {order_id} (amount: {amount}) with details: {refund_description}" - - -def create_agents(client: AzureOpenAIChatClient) -> tuple[Agent, Agent, Agent]: - """Create a simple handoff scenario: triage, refund, and order specialists.""" - - triage = client.as_agent( - name="triage_agent", - instructions=( - "You are a customer service triage agent. Listen to customer issues and determine " - "if they need refund help or order tracking. Use handoff_to_refund_agent or " - "handoff_to_order_agent to transfer them." - ), - ) - - refund = client.as_agent( - name="refund_agent", - instructions=( - "You are a refund specialist. Help customers with refund requests. " - "Be empathetic and ask for order numbers if not provided. " - "When the user confirms they want a refund and supplies order details, call submit_refund " - "to record the request before continuing." - ), - tools=[submit_refund], - ) - - order = client.as_agent( - name="order_agent", - instructions=( - "You are an order tracking specialist. Help customers track their orders. " - "Ask for order numbers and provide shipping updates." - ), - ) - - return triage, refund, order - - -def create_workflow(checkpoint_storage: FileCheckpointStorage) -> tuple[Workflow, Agent, Agent, Agent]: - """Build the handoff workflow with checkpointing enabled.""" - - client = AzureOpenAIChatClient(credential=AzureCliCredential()) - triage, refund, order = create_agents(client) - - # checkpoint_storage: Enable checkpointing for resume - # termination_condition: Terminate after 5 user messages for this demo - workflow = ( - HandoffBuilder( - name="checkpoint_handoff_demo", - participants=[triage, refund, order], - checkpoint_storage=checkpoint_storage, - termination_condition=lambda conv: sum(1 for msg in conv if msg.role == "user") >= 5, - ) - .with_start_agent(triage) - .build() - ) - - return workflow, triage, refund, order - - -def _print_handoff_agent_user_request(response: AgentResponse) -> None: - """Display the agent's response messages when requesting user input.""" - if not response.messages: - print("(No agent messages)") - return - - print("\n[Agent is requesting your input...]") - for message in response.messages: - if not message.text: - continue - speaker = message.author_name or message.role - print(f" {speaker}: {message.text}") - - -def _print_handoff_request(request: HandoffAgentUserRequest, request_id: str) -> None: - """Log pending handoff request details for debugging.""" - print(f"\n{'=' * 60}") - print("WORKFLOW PAUSED - User input needed") - print(f"Request ID: {request_id}") - print(f"Awaiting agent: {request.agent_response.agent_id}") - - _print_handoff_agent_user_request(request.agent_response) - - print(f"{'=' * 60}\n") - - -def _print_function_approval_request(request: Content, request_id: str) -> None: - """Log pending tool approval details for debugging.""" - args = request.function_call.parse_arguments() or {} # type: ignore - print(f"\n{'=' * 60}") - print("WORKFLOW PAUSED - Tool approval required") - print(f"Request ID: {request_id}") - print(f"Function: {request.function_call.name}") # type: ignore - print(f"Arguments:\n{json.dumps(args, indent=2)}") - print(f"{'=' * 60}\n") - - -def _build_responses_for_requests( - pending_requests: list[WorkflowEvent], - *, - user_response: str | None, - approve_tools: bool | None, -) -> dict[str, object]: - """Create response payloads for each pending request.""" - responses: dict[str, object] = {} - for request in pending_requests: - if isinstance(request.data, HandoffAgentUserRequest) and request.request_id: - if user_response is None: - raise ValueError("User response is required for HandoffAgentUserRequest") - responses[request.request_id] = user_response - elif ( - isinstance(request.data, Content) - and request.data.type == "function_approval_request" - and request.request_id - ): - if approve_tools is None: - raise ValueError("Approval decision is required for function approval request") - responses[request.request_id] = request.data.to_function_approval_response(approved=approve_tools) - else: - raise ValueError(f"Unsupported request type: {type(request.data)}") - return responses - - -async def run_until_user_input_needed( - workflow: Workflow, - initial_message: str | None = None, - checkpoint_id: str | None = None, -) -> tuple[list[WorkflowEvent], str | None]: - """ - Run the workflow until it needs user input or approval, or completes. - - Returns: - Tuple of (pending_requests, checkpoint_id_to_use_for_resume) - """ - pending_requests: list[WorkflowEvent] = [] - latest_checkpoint_id: str | None = checkpoint_id - - if initial_message: - print(f"\nStarting workflow with: {initial_message}\n") - event_stream = workflow.run(message=initial_message, stream=True) # type: ignore[attr-defined] - elif checkpoint_id: - print(f"\nResuming workflow from checkpoint: {checkpoint_id}\n") - event_stream = workflow.run(checkpoint_id=checkpoint_id, stream=True) # type: ignore[attr-defined] - else: - raise ValueError("Must provide either initial_message or checkpoint_id") - - async for event in event_stream: - if event.type == "status": - print(f"[Status] {event.state}") - - elif event.type == "request_info": - pending_requests.append(event) - if isinstance(event.data, HandoffAgentUserRequest): - _print_handoff_request(event.data, event.request_id) - elif isinstance(event.data, Content) and event.data.type == "function_approval_request": - _print_function_approval_request(event.data, event.request_id) - - elif event.type == "output": - print("\n[Workflow Completed]") - if event.data: - print(f"Final conversation length: {len(event.data)} messages") - return [], None - - # Workflow paused with pending requests - # The latest checkpoint was created at the end of the last superstep - # We'll use the checkpoint storage to find it - return pending_requests, latest_checkpoint_id - - -async def resume_with_responses( - workflow: Workflow, - checkpoint_storage: FileCheckpointStorage, - user_response: str | None = None, - approve_tools: bool | None = None, -) -> tuple[list[WorkflowEvent], str | None]: - """ - Resume from checkpoint and send responses. - - Step 1: Restore checkpoint to discover pending request types. - Step 2: Build typed responses and send via workflow.run(responses=...). - - When response payloads are already known, these can be combined into a single - workflow.run(stream=True, checkpoint_id=..., responses=...) call. - """ - print(f"\n{'=' * 60}") - print("RESUMING WORKFLOW WITH HUMAN INPUT") - if user_response is not None: - print(f"User says: {user_response}") - if approve_tools is not None: - print(f"Approve tools: {approve_tools}") - print(f"{'=' * 60}\n") - - # Get the latest checkpoint - checkpoints = await checkpoint_storage.list_checkpoints() - if not checkpoints: - raise RuntimeError("No checkpoints found to resume from") - - # Sort by timestamp to get latest - checkpoints.sort(key=lambda cp: cp.timestamp, reverse=True) - latest_checkpoint = checkpoints[0] - - print(f"Restoring checkpoint {latest_checkpoint.checkpoint_id}") - - # First, restore checkpoint to discover pending requests - restored_requests: list[WorkflowEvent] = [] - async for event in workflow.run(checkpoint_id=latest_checkpoint.checkpoint_id, stream=True): # type: ignore[attr-defined] - if event.type == "request_info": - restored_requests.append(event) - if isinstance(event.data, HandoffAgentUserRequest): - _print_handoff_request(event.data, event.request_id) - elif isinstance(event.data, Content) and event.data.type == "function_approval_request": - _print_function_approval_request(event.data, event.request_id) - - if not restored_requests: - raise RuntimeError("No pending requests found after checkpoint restoration") - - responses = _build_responses_for_requests( - restored_requests, - user_response=user_response, - approve_tools=approve_tools, - ) - print(f"Sending responses for {len(responses)} request(s)") - - new_pending_requests: list[WorkflowEvent] = [] - - async for event in workflow.run(stream=True, responses=responses): - if event.type == "status": - print(f"[Status] {event.state}") - - elif event.type == "output": - print("\n[Workflow Output Event - Conversation Update]") - if event.data and isinstance(event.data, list) and all(isinstance(msg, Message) for msg in event.data): # type: ignore - # Now safe to cast event.data to list[Message] - conversation = cast(list[Message], event.data) # type: ignore - for msg in conversation[-3:]: # Show last 3 messages - author = msg.author_name or msg.role - text = msg.text[:100] + "..." if len(msg.text) > 100 else msg.text - print(f" {author}: {text}") - - elif event.type == "request_info": - new_pending_requests.append(event) - if isinstance(event.data, HandoffAgentUserRequest): - _print_handoff_request(event.data, event.request_id) - elif isinstance(event.data, Content) and event.data.type == "function_approval_request": - _print_function_approval_request(event.data, event.request_id) - - return new_pending_requests, latest_checkpoint.checkpoint_id - - -async def main() -> None: - """ - Demonstrate the checkpoint-based pause/resume pattern for handoff workflows. - - This sample shows: - 1. Starting a workflow and getting a HandoffAgentUserRequest - 2. Pausing (checkpoint is saved automatically) - 3. Resuming from checkpoint with a user response or tool approval - 4. Continuing the conversation until completion - """ - - # Enable INFO logging to see workflow progress - logging.basicConfig( - level=logging.INFO, - format="[%(levelname)s] %(name)s: %(message)s", - ) - - # Clean up old checkpoints - for file in CHECKPOINT_DIR.glob("*.json"): - file.unlink() - for file in CHECKPOINT_DIR.glob("*.json.tmp"): - file.unlink() - - storage = FileCheckpointStorage(storage_path=CHECKPOINT_DIR) - workflow, _, _, _ = create_workflow(checkpoint_storage=storage) - - print("=" * 60) - print("HANDOFF WORKFLOW CHECKPOINT DEMO") - print("=" * 60) - - # Scenario: User needs help with a damaged order - initial_request = "Hi, my order 12345 arrived damaged. I need a refund." - - # Phase 1: Initial run - workflow will pause when it needs user input - pending_requests, _ = await run_until_user_input_needed( - workflow, - initial_message=initial_request, - ) - - if not pending_requests: - print("Workflow completed without needing user input") - return - - print("\n>>> Workflow paused. You could exit the process here.") - print(f">>> Checkpoint was saved. Pending requests: {len(pending_requests)}") - - # Scripted human input for demo purposes - handoff_responses = [ - ( - "The headphones in order 12345 arrived cracked. " - "Please submit the refund for $89.99 and send a replacement to my original address." - ), - "Yes, that covers the damage and refund request.", - "That's everything I needed for the refund.", - "Thanks for handling the refund.", - ] - approval_decisions = [True, True, True] - handoff_index = 0 - approval_index = 0 - - while pending_requests: - print("\n>>> Simulating process restart...\n") - workflow_step, _, _, _ = create_workflow(checkpoint_storage=storage) - - needs_user_input = any(isinstance(req.data, HandoffAgentUserRequest) for req in pending_requests) - needs_tool_approval = any( - isinstance(req.data, Content) and req.data.type == "function_approval_request" for req in pending_requests - ) - - user_response = None - if needs_user_input: - if handoff_index < len(handoff_responses): - user_response = handoff_responses[handoff_index] - handoff_index += 1 - else: - user_response = handoff_responses[-1] - print(f">>> Responding to handoff request with: {user_response}") - - approval_response = None - if needs_tool_approval: - if approval_index < len(approval_decisions): - approval_response = approval_decisions[approval_index] - approval_index += 1 - else: - approval_response = approval_decisions[-1] - print(">>> Approving pending tool calls from the agent.") - - pending_requests, _ = await resume_with_responses( - workflow_step, - storage, - user_response=user_response, - approve_tools=approval_response, - ) - - print("\n" + "=" * 60) - print("DEMO COMPLETE") - print("=" * 60) - - -if __name__ == "__main__": - asyncio.run(main()) diff --git a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py index b93a58a50c..833bd7c920 100644 --- a/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py +++ b/python/samples/getting_started/workflows/checkpoint/sub_workflow_checkpoint.py @@ -345,14 +345,12 @@ async def main() -> None: if request_id is None: raise RuntimeError("Sub-workflow completed without requesting review.") - checkpoints = await storage.list_checkpoints(workflow.id) - if not checkpoints: + resume_checkpoint = await storage.get_latest(workflow_name=workflow.name) + if not resume_checkpoint: raise RuntimeError("No checkpoints found.") # Print the checkpoint to show pending requests # We didn't handle the request above so the request is still pending the last checkpoint - checkpoints.sort(key=lambda cp: cp.timestamp) - resume_checkpoint = checkpoints[-1] print(f"Using checkpoint {resume_checkpoint.checkpoint_id} at iteration {resume_checkpoint.iteration_count}") checkpoint_path = storage.storage_path / f"{resume_checkpoint.checkpoint_id}.json" diff --git a/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py b/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py index 4fc980e008..552ced2892 100644 --- a/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py +++ b/python/samples/getting_started/workflows/checkpoint/workflow_as_agent_checkpoint.py @@ -69,7 +69,7 @@ async def basic_checkpointing() -> None: print(f"[{speaker}]: {msg.text}") # Show checkpoints that were created - checkpoints = await checkpoint_storage.list_checkpoints(workflow.id) + checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name) print(f"\nCheckpoints created: {len(checkpoints)}") for i, cp in enumerate(checkpoints[:5], 1): print(f" {i}. {cp.checkpoint_id}") @@ -110,7 +110,7 @@ async def checkpointing_with_thread() -> None: print(f"[assistant]: {response2.messages[0].text}") # Show accumulated state - checkpoints = await checkpoint_storage.list_checkpoints(workflow.id) + checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name) print(f"\nTotal checkpoints across both turns: {len(checkpoints)}") if thread.message_store: @@ -147,7 +147,7 @@ async def streaming_with_checkpoints() -> None: print() # Newline after streaming - checkpoints = await checkpoint_storage.list_checkpoints(workflow.id) + checkpoints = await checkpoint_storage.list_checkpoints(workflow_name=workflow.name) print(f"\nCheckpoints created during stream: {len(checkpoints)}") diff --git a/python/uv.lock b/python/uv.lock index 24396ef396..ff7328ebf1 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -331,7 +331,7 @@ dependencies = [ { name = "opentelemetry-semantic-conventions-ai", 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'" }, { name = "pydantic", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "pydantic-settings", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "python-dotenv", 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'" }, ] @@ -386,7 +386,7 @@ requires-dist = [ { name = "opentelemetry-semantic-conventions-ai", specifier = ">=0.4.13" }, { name = "packaging", specifier = ">=24.1" }, { name = "pydantic", specifier = ">=2,<3" }, - { name = "pydantic-settings", specifier = ">=2,<3" }, + { name = "python-dotenv", specifier = ">=1,<2" }, { name = "typing-extensions" }, ] provides-extras = ["all"] @@ -1000,15 +1000,15 @@ wheels = [ [[package]] name = "azure-core" -version = "1.38.0" +version = "1.38.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "requests", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/dc/1b/e503e08e755ea94e7d3419c9242315f888fc664211c90d032e40479022bf/azure_core-1.38.0.tar.gz", hash = "sha256:8194d2682245a3e4e3151a667c686464c3786fed7918b394d035bdcd61bb5993", size = 363033, upload-time = "2026-01-12T17:03:05.535Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/9b/23893febea484ad8183112c9419b5eb904773adb871492b5fa8ff7b21e09/azure_core-1.38.1.tar.gz", hash = "sha256:9317db1d838e39877eb94a2240ce92fa607db68adf821817b723f0d679facbf6", size = 363323, upload-time = "2026-02-11T02:03:06.051Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fc/d8/b8fcba9464f02b121f39de2db2bf57f0b216fe11d014513d666e8634380d/azure_core-1.38.0-py3-none-any.whl", hash = "sha256:ab0c9b2cd71fecb1842d52c965c95285d3cfb38902f6766e4a471f1cd8905335", size = 217825, upload-time = "2026-01-12T17:03:07.291Z" }, + { url = "https://files.pythonhosted.org/packages/db/88/aaea2ad269ce70b446660371286272c1f6ba66541a7f6f635baf8b0db726/azure_core-1.38.1-py3-none-any.whl", hash = "sha256:69f08ee3d55136071b7100de5b198994fc1c5f89d2b91f2f43156d20fcf200a4", size = 217930, upload-time = "2026-02-11T02:03:07.548Z" }, ] [[package]] @@ -1043,7 +1043,7 @@ wheels = [ [[package]] name = "azure-identity" -version = "1.25.1" +version = "1.25.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "azure-core", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1052,9 +1052,9 @@ dependencies = [ { name = "msal-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/06/8d/1a6c41c28a37eab26dc85ab6c86992c700cd3f4a597d9ed174b0e9c69489/azure_identity-1.25.1.tar.gz", hash = "sha256:87ca8328883de6036443e1c37b40e8dc8fb74898240f61071e09d2e369361456", size = 279826, upload-time = "2025-10-06T20:30:02.194Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c2/3a/439a32a5e23e45f6a91f0405949dc66cfe6834aba15a430aebfc063a81e7/azure_identity-1.25.2.tar.gz", hash = "sha256:030dbaa720266c796221c6cdbd1999b408c079032c919fef725fcc348a540fe9", size = 284709, upload-time = "2026-02-11T01:55:42.323Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/7b/5652771e24fff12da9dde4c20ecf4682e606b104f26419d139758cc935a6/azure_identity-1.25.1-py3-none-any.whl", hash = "sha256:e9edd720af03dff020223cd269fa3a61e8f345ea75443858273bcb44844ab651", size = 191317, upload-time = "2025-10-06T20:30:04.251Z" }, + { url = "https://files.pythonhosted.org/packages/9b/77/f658c76f9e9a52c784bd836aaca6fd5b9aae176f1f53273e758a2bcda695/azure_identity-1.25.2-py3-none-any.whl", hash = "sha256:1b40060553d01a72ba0d708b9a46d0f61f56312e215d8896d836653ffdc6753d", size = 191423, upload-time = "2026-02-11T01:55:44.245Z" }, ] [[package]] @@ -1324,19 +1324,19 @@ wheels = [ [[package]] name = "claude-agent-sdk" -version = "0.1.34" +version = "0.1.35" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "mcp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/69/faeb64e9c8f0962cbf12bee1b959acc41f87c82947ec7074a6780b417001/claude_agent_sdk-0.1.34.tar.gz", hash = "sha256:db9e4023a754d9a58a0793666fe9174ead277197cd896156d2f8784cc73c5006", size = 61196, upload-time = "2026-02-10T01:04:00.585Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4d/b9/60f21337cfb0d029cbafefdb5de5d043a5d84e44f286f04dbadda7fda932/claude_agent_sdk-0.1.35.tar.gz", hash = "sha256:0f98e2b3c71ca85abfc042e7a35c648df88e87fda41c52e6779ef7b038dcbb52", size = 61194, upload-time = "2026-02-10T23:21:04.114Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e7/7b/2ccdc12b553a61b59b0470f1cf3b0a864c79ab8a4ac8013ea22fa3e6c461/claude_agent_sdk-0.1.34-py3-none-macosx_11_0_arm64.whl", hash = "sha256:18569ab4bfb5451c4aacb51c0d44eb9802d18d8442d30c29f32b6e8a2479d210", size = 54604881, upload-time = "2026-02-10T01:03:44.575Z" }, - { url = "https://files.pythonhosted.org/packages/38/59/335b213fb3342c4405fa992cc9e45e52e18a543068f0574ae84010dc4c08/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:d7ecd7421066e405376d3feca21ccb3e9245506ba7c219858f7a7f0129877cdb", size = 69359030, upload-time = "2026-02-10T01:03:49.113Z" }, - { url = "https://files.pythonhosted.org/packages/93/3d/28c715efdad7b5c413e046f5f914d9ab888d25f2c3bb9233f1164d58d2be/claude_agent_sdk-0.1.34-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:82e9148410ec98ff4061e43e85601d8f0a2e8568d897ab82c324ccf11c297fc5", size = 69949555, upload-time = "2026-02-10T01:03:53.525Z" }, - { url = "https://files.pythonhosted.org/packages/12/3d/843159343b20d6c9b44cf4a7fe46b568d5b448276ba8ba4c49178d26ba4c/claude_agent_sdk-0.1.34-py3-none-win_amd64.whl", hash = "sha256:a64031a9bf5c70388a6a84368d350d68586d9854a1539f494b46ec6d0b6acf93", size = 72493949, upload-time = "2026-02-10T01:03:57.839Z" }, + { url = "https://files.pythonhosted.org/packages/b9/1a/68ca97f034a1773bd234a4572e1a660d3f37b93e8ab9c8da95c36a10fd00/claude_agent_sdk-0.1.35-py3-none-macosx_11_0_arm64.whl", hash = "sha256:df67f4deade77b16a9678b3a626c176498e40417f33b04beda9628287f375591", size = 54665881, upload-time = "2026-02-10T23:20:48.107Z" }, + { url = "https://files.pythonhosted.org/packages/4e/56/535f23919882397571e15f4fb0418897ba9b527dc3a8a6c84b4f537486e0/claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_aarch64.whl", hash = "sha256:14963944f55ded7c8ed518feebfa5b4284aa6dd8d81aeff2e5b21a962ce65097", size = 69419673, upload-time = "2026-02-10T23:20:53.463Z" }, + { url = "https://files.pythonhosted.org/packages/e9/b5/763dda7d4d8c12a2ce485ef5cc98f2e7d2699bf3e0d8e2a7fb294d54c34b/claude_agent_sdk-0.1.35-py3-none-manylinux_2_17_x86_64.whl", hash = "sha256:84344dcc535d179c1fc8a11c6f34c37c3b583447bdf09d869effb26514fd7a65", size = 70007339, upload-time = "2026-02-10T23:20:57.629Z" }, + { url = "https://files.pythonhosted.org/packages/9e/89/10f3d0355ee873203104714d2d728315ee5919c793e3626fab94a91ea29b/claude_agent_sdk-0.1.35-py3-none-win_amd64.whl", hash = "sha256:1b3d54b47448c93f6f372acd4d1757f047c3c1e8ef5804be7a1e3e53e2c79a5f", size = 72528072, upload-time = "2026-02-10T23:21:01.418Z" }, ] [[package]] @@ -1356,7 +1356,7 @@ name = "clr-loader" version = "0.2.10" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/24/c12faf3f61614b3131b5c98d3bf0d376b49c7feaa73edca559aeb2aee080/clr_loader-0.2.10.tar.gz", hash = "sha256:81f114afbc5005bafc5efe5af1341d400e22137e275b042a8979f3feb9fc9446", size = 83605, upload-time = "2026-01-03T23:13:06.984Z" } wheels = [ @@ -1673,62 +1673,62 @@ wheels = [ [[package]] name = "cryptography" -version = "46.0.4" +version = "46.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cffi", marker = "(platform_python_implementation != 'PyPy' and sys_platform == 'darwin') or (platform_python_implementation != 'PyPy' and sys_platform == 'linux') or (platform_python_implementation != 'PyPy' and sys_platform == 'win32')" }, { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/04/ee2a9e8542e4fa2773b81771ff8349ff19cdd56b7258a0cc442639052edb/cryptography-46.0.5.tar.gz", hash = "sha256:abace499247268e3757271b2f1e244b36b06f8515cf27c4d49468fc9eb16e93d", size = 750064, upload-time = "2026-02-10T19:18:38.255Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, - { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, - { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, - { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, - { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, - { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, - { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, - { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, - { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, - { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, - { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, - { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, - { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, - { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, - { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, - { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, - { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, - { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, - { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, - { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, - { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, - { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, - { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, - { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, - { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, - { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, - { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, - { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, - { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, - { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, - { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, - { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, - { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, - { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, - { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, - { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, - { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, - { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, - { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, - { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, - { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, - { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, - { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, + { url = "https://files.pythonhosted.org/packages/f7/81/b0bb27f2ba931a65409c6b8a8b358a7f03c0e46eceacddff55f7c84b1f3b/cryptography-46.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:351695ada9ea9618b3500b490ad54c739860883df6c1f555e088eaf25b1bbaad", size = 7176289, upload-time = "2026-02-10T19:17:08.274Z" }, + { url = "https://files.pythonhosted.org/packages/ff/9e/6b4397a3e3d15123de3b1806ef342522393d50736c13b20ec4c9ea6693a6/cryptography-46.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c18ff11e86df2e28854939acde2d003f7984f721eba450b56a200ad90eeb0e6b", size = 4275637, upload-time = "2026-02-10T19:17:10.53Z" }, + { url = "https://files.pythonhosted.org/packages/63/e7/471ab61099a3920b0c77852ea3f0ea611c9702f651600397ac567848b897/cryptography-46.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d7e3d356b8cd4ea5aff04f129d5f66ebdc7b6f8eae802b93739ed520c47c79b", size = 4424742, upload-time = "2026-02-10T19:17:12.388Z" }, + { url = "https://files.pythonhosted.org/packages/37/53/a18500f270342d66bf7e4d9f091114e31e5ee9e7375a5aba2e85a91e0044/cryptography-46.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:50bfb6925eff619c9c023b967d5b77a54e04256c4281b0e21336a130cd7fc263", size = 4277528, upload-time = "2026-02-10T19:17:13.853Z" }, + { url = "https://files.pythonhosted.org/packages/22/29/c2e812ebc38c57b40e7c583895e73c8c5adb4d1e4a0cc4c5a4fdab2b1acc/cryptography-46.0.5-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:803812e111e75d1aa73690d2facc295eaefd4439be1023fefc4995eaea2af90d", size = 4947993, upload-time = "2026-02-10T19:17:15.618Z" }, + { url = "https://files.pythonhosted.org/packages/6b/e7/237155ae19a9023de7e30ec64e5d99a9431a567407ac21170a046d22a5a3/cryptography-46.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ee190460e2fbe447175cda91b88b84ae8322a104fc27766ad09428754a618ed", size = 4456855, upload-time = "2026-02-10T19:17:17.221Z" }, + { url = "https://files.pythonhosted.org/packages/2d/87/fc628a7ad85b81206738abbd213b07702bcbdada1dd43f72236ef3cffbb5/cryptography-46.0.5-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:f145bba11b878005c496e93e257c1e88f154d278d2638e6450d17e0f31e558d2", size = 3984635, upload-time = "2026-02-10T19:17:18.792Z" }, + { url = "https://files.pythonhosted.org/packages/84/29/65b55622bde135aedf4565dc509d99b560ee4095e56989e815f8fd2aa910/cryptography-46.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e9251e3be159d1020c4030bd2e5f84d6a43fe54b6c19c12f51cde9542a2817b2", size = 4277038, upload-time = "2026-02-10T19:17:20.256Z" }, + { url = "https://files.pythonhosted.org/packages/bc/36/45e76c68d7311432741faf1fbf7fac8a196a0a735ca21f504c75d37e2558/cryptography-46.0.5-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:47fb8a66058b80e509c47118ef8a75d14c455e81ac369050f20ba0d23e77fee0", size = 4912181, upload-time = "2026-02-10T19:17:21.825Z" }, + { url = "https://files.pythonhosted.org/packages/6d/1a/c1ba8fead184d6e3d5afcf03d569acac5ad063f3ac9fb7258af158f7e378/cryptography-46.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4c3341037c136030cb46e4b1e17b7418ea4cbd9dd207e4a6f3b2b24e0d4ac731", size = 4456482, upload-time = "2026-02-10T19:17:25.133Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e5/3fb22e37f66827ced3b902cf895e6a6bc1d095b5b26be26bd13c441fdf19/cryptography-46.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:890bcb4abd5a2d3f852196437129eb3667d62630333aacc13dfd470fad3aaa82", size = 4405497, upload-time = "2026-02-10T19:17:26.66Z" }, + { url = "https://files.pythonhosted.org/packages/1a/df/9d58bb32b1121a8a2f27383fabae4d63080c7ca60b9b5c88be742be04ee7/cryptography-46.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80a8d7bfdf38f87ca30a5391c0c9ce4ed2926918e017c29ddf643d0ed2778ea1", size = 4667819, upload-time = "2026-02-10T19:17:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/ea/ed/325d2a490c5e94038cdb0117da9397ece1f11201f425c4e9c57fe5b9f08b/cryptography-46.0.5-cp311-abi3-win32.whl", hash = "sha256:60ee7e19e95104d4c03871d7d7dfb3d22ef8a9b9c6778c94e1c8fcc8365afd48", size = 3028230, upload-time = "2026-02-10T19:17:30.518Z" }, + { url = "https://files.pythonhosted.org/packages/e9/5a/ac0f49e48063ab4255d9e3b79f5def51697fce1a95ea1370f03dc9db76f6/cryptography-46.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:38946c54b16c885c72c4f59846be9743d699eee2b69b6988e0a00a01f46a61a4", size = 3480909, upload-time = "2026-02-10T19:17:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/00/13/3d278bfa7a15a96b9dc22db5a12ad1e48a9eb3d40e1827ef66a5df75d0d0/cryptography-46.0.5-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:94a76daa32eb78d61339aff7952ea819b1734b46f73646a07decb40e5b3448e2", size = 7119287, upload-time = "2026-02-10T19:17:33.801Z" }, + { url = "https://files.pythonhosted.org/packages/67/c8/581a6702e14f0898a0848105cbefd20c058099e2c2d22ef4e476dfec75d7/cryptography-46.0.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5be7bf2fb40769e05739dd0046e7b26f9d4670badc7b032d6ce4db64dddc0678", size = 4265728, upload-time = "2026-02-10T19:17:35.569Z" }, + { url = "https://files.pythonhosted.org/packages/dd/4a/ba1a65ce8fc65435e5a849558379896c957870dd64fecea97b1ad5f46a37/cryptography-46.0.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe346b143ff9685e40192a4960938545c699054ba11d4f9029f94751e3f71d87", size = 4408287, upload-time = "2026-02-10T19:17:36.938Z" }, + { url = "https://files.pythonhosted.org/packages/f8/67/8ffdbf7b65ed1ac224d1c2df3943553766914a8ca718747ee3871da6107e/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c69fd885df7d089548a42d5ec05be26050ebcd2283d89b3d30676eb32ff87dee", size = 4270291, upload-time = "2026-02-10T19:17:38.748Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e5/f52377ee93bc2f2bba55a41a886fd208c15276ffbd2569f2ddc89d50e2c5/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:8293f3dea7fc929ef7240796ba231413afa7b68ce38fd21da2995549f5961981", size = 4927539, upload-time = "2026-02-10T19:17:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/3b/02/cfe39181b02419bbbbcf3abdd16c1c5c8541f03ca8bda240debc467d5a12/cryptography-46.0.5-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1abfdb89b41c3be0365328a410baa9df3ff8a9110fb75e7b52e66803ddabc9a9", size = 4442199, upload-time = "2026-02-10T19:17:41.789Z" }, + { url = "https://files.pythonhosted.org/packages/c0/96/2fcaeb4873e536cf71421a388a6c11b5bc846e986b2b069c79363dc1648e/cryptography-46.0.5-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:d66e421495fdb797610a08f43b05269e0a5ea7f5e652a89bfd5a7d3c1dee3648", size = 3960131, upload-time = "2026-02-10T19:17:43.379Z" }, + { url = "https://files.pythonhosted.org/packages/d8/d2/b27631f401ddd644e94c5cf33c9a4069f72011821cf3dc7309546b0642a0/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:4e817a8920bfbcff8940ecfd60f23d01836408242b30f1a708d93198393a80b4", size = 4270072, upload-time = "2026-02-10T19:17:45.481Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a7/60d32b0370dae0b4ebe55ffa10e8599a2a59935b5ece1b9f06edb73abdeb/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:68f68d13f2e1cb95163fa3b4db4bf9a159a418f5f6e7242564fc75fcae667fd0", size = 4892170, upload-time = "2026-02-10T19:17:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/d2/b9/cf73ddf8ef1164330eb0b199a589103c363afa0cf794218c24d524a58eab/cryptography-46.0.5-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:a3d1fae9863299076f05cb8a778c467578262fae09f9dc0ee9b12eb4268ce663", size = 4441741, upload-time = "2026-02-10T19:17:48.661Z" }, + { url = "https://files.pythonhosted.org/packages/5f/eb/eee00b28c84c726fe8fa0158c65afe312d9c3b78d9d01daf700f1f6e37ff/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4143987a42a2397f2fc3b4d7e3a7d313fbe684f67ff443999e803dd75a76826", size = 4396728, upload-time = "2026-02-10T19:17:50.058Z" }, + { url = "https://files.pythonhosted.org/packages/65/f4/6bc1a9ed5aef7145045114b75b77c2a8261b4d38717bd8dea111a63c3442/cryptography-46.0.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7d731d4b107030987fd61a7f8ab512b25b53cef8f233a97379ede116f30eb67d", size = 4652001, upload-time = "2026-02-10T19:17:51.54Z" }, + { url = "https://files.pythonhosted.org/packages/86/ef/5d00ef966ddd71ac2e6951d278884a84a40ffbd88948ef0e294b214ae9e4/cryptography-46.0.5-cp314-cp314t-win32.whl", hash = "sha256:c3bcce8521d785d510b2aad26ae2c966092b7daa8f45dd8f44734a104dc0bc1a", size = 3003637, upload-time = "2026-02-10T19:17:52.997Z" }, + { url = "https://files.pythonhosted.org/packages/b7/57/f3f4160123da6d098db78350fdfd9705057aad21de7388eacb2401dceab9/cryptography-46.0.5-cp314-cp314t-win_amd64.whl", hash = "sha256:4d8ae8659ab18c65ced284993c2265910f6c9e650189d4e3f68445ef82a810e4", size = 3469487, upload-time = "2026-02-10T19:17:54.549Z" }, + { url = "https://files.pythonhosted.org/packages/e2/fa/a66aa722105ad6a458bebd64086ca2b72cdd361fed31763d20390f6f1389/cryptography-46.0.5-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:4108d4c09fbbf2789d0c926eb4152ae1760d5a2d97612b92d508d96c861e4d31", size = 7170514, upload-time = "2026-02-10T19:17:56.267Z" }, + { url = "https://files.pythonhosted.org/packages/0f/04/c85bdeab78c8bc77b701bf0d9bdcf514c044e18a46dcff330df5448631b0/cryptography-46.0.5-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7d1f30a86d2757199cb2d56e48cce14deddf1f9c95f1ef1b64ee91ea43fe2e18", size = 4275349, upload-time = "2026-02-10T19:17:58.419Z" }, + { url = "https://files.pythonhosted.org/packages/5c/32/9b87132a2f91ee7f5223b091dc963055503e9b442c98fc0b8a5ca765fab0/cryptography-46.0.5-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:039917b0dc418bb9f6edce8a906572d69e74bd330b0b3fea4f79dab7f8ddd235", size = 4420667, upload-time = "2026-02-10T19:18:00.619Z" }, + { url = "https://files.pythonhosted.org/packages/a1/a6/a7cb7010bec4b7c5692ca6f024150371b295ee1c108bdc1c400e4c44562b/cryptography-46.0.5-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:ba2a27ff02f48193fc4daeadf8ad2590516fa3d0adeeb34336b96f7fa64c1e3a", size = 4276980, upload-time = "2026-02-10T19:18:02.379Z" }, + { url = "https://files.pythonhosted.org/packages/8e/7c/c4f45e0eeff9b91e3f12dbd0e165fcf2a38847288fcfd889deea99fb7b6d/cryptography-46.0.5-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:61aa400dce22cb001a98014f647dc21cda08f7915ceb95df0c9eaf84b4b6af76", size = 4939143, upload-time = "2026-02-10T19:18:03.964Z" }, + { url = "https://files.pythonhosted.org/packages/37/19/e1b8f964a834eddb44fa1b9a9976f4e414cbb7aa62809b6760c8803d22d1/cryptography-46.0.5-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3ce58ba46e1bc2aac4f7d9290223cead56743fa6ab94a5d53292ffaac6a91614", size = 4453674, upload-time = "2026-02-10T19:18:05.588Z" }, + { url = "https://files.pythonhosted.org/packages/db/ed/db15d3956f65264ca204625597c410d420e26530c4e2943e05a0d2f24d51/cryptography-46.0.5-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:420d0e909050490d04359e7fdb5ed7e667ca5c3c402b809ae2563d7e66a92229", size = 3978801, upload-time = "2026-02-10T19:18:07.167Z" }, + { url = "https://files.pythonhosted.org/packages/41/e2/df40a31d82df0a70a0daf69791f91dbb70e47644c58581d654879b382d11/cryptography-46.0.5-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:582f5fcd2afa31622f317f80426a027f30dc792e9c80ffee87b993200ea115f1", size = 4276755, upload-time = "2026-02-10T19:18:09.813Z" }, + { url = "https://files.pythonhosted.org/packages/33/45/726809d1176959f4a896b86907b98ff4391a8aa29c0aaaf9450a8a10630e/cryptography-46.0.5-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:bfd56bb4b37ed4f330b82402f6f435845a5f5648edf1ad497da51a8452d5d62d", size = 4901539, upload-time = "2026-02-10T19:18:11.263Z" }, + { url = "https://files.pythonhosted.org/packages/99/0f/a3076874e9c88ecb2ecc31382f6e7c21b428ede6f55aafa1aa272613e3cd/cryptography-46.0.5-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a3d507bb6a513ca96ba84443226af944b0f7f47dcc9a399d110cd6146481d24c", size = 4452794, upload-time = "2026-02-10T19:18:12.914Z" }, + { url = "https://files.pythonhosted.org/packages/02/ef/ffeb542d3683d24194a38f66ca17c0a4b8bf10631feef44a7ef64e631b1a/cryptography-46.0.5-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9f16fbdf4da055efb21c22d81b89f155f02ba420558db21288b3d0035bafd5f4", size = 4404160, upload-time = "2026-02-10T19:18:14.375Z" }, + { url = "https://files.pythonhosted.org/packages/96/93/682d2b43c1d5f1406ed048f377c0fc9fc8f7b0447a478d5c65ab3d3a66eb/cryptography-46.0.5-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:ced80795227d70549a411a4ab66e8ce307899fad2220ce5ab2f296e687eacde9", size = 4667123, upload-time = "2026-02-10T19:18:15.886Z" }, + { url = "https://files.pythonhosted.org/packages/45/2d/9c5f2926cb5300a8eefc3f4f0b3f3df39db7f7ce40c8365444c49363cbda/cryptography-46.0.5-cp38-abi3-win32.whl", hash = "sha256:02f547fce831f5096c9a567fd41bc12ca8f11df260959ecc7c3202555cc47a72", size = 3010220, upload-time = "2026-02-10T19:18:17.361Z" }, + { url = "https://files.pythonhosted.org/packages/48/ef/0c2f4a8e31018a986949d34a01115dd057bf536905dca38897bacd21fac3/cryptography-46.0.5-cp38-abi3-win_amd64.whl", hash = "sha256:556e106ee01aa13484ce9b0239bca667be5004efb0aabbed28d353df86445595", size = 3467050, upload-time = "2026-02-10T19:18:18.899Z" }, + { url = "https://files.pythonhosted.org/packages/eb/dd/2d9fdb07cebdf3d51179730afb7d5e576153c6744c3ff8fded23030c204e/cryptography-46.0.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:3b4995dc971c9fb83c25aa44cf45f02ba86f71ee600d81091c2f0cbae116b06c", size = 3476964, upload-time = "2026-02-10T19:18:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/e9/6f/6cc6cc9955caa6eaf83660b0da2b077c7fe8ff9950a3c5e45d605038d439/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bc84e875994c3b445871ea7181d424588171efec3e185dced958dad9e001950a", size = 4218321, upload-time = "2026-02-10T19:18:22.349Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5d/c4da701939eeee699566a6c1367427ab91a8b7088cc2328c09dbee940415/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:2ae6971afd6246710480e3f15824ed3029a60fc16991db250034efd0b9fb4356", size = 4381786, upload-time = "2026-02-10T19:18:24.529Z" }, + { url = "https://files.pythonhosted.org/packages/ac/97/a538654732974a94ff96c1db621fa464f455c02d4bb7d2652f4edc21d600/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d861ee9e76ace6cf36a6a89b959ec08e7bc2493ee39d07ffe5acb23ef46d27da", size = 4217990, upload-time = "2026-02-10T19:18:25.957Z" }, + { url = "https://files.pythonhosted.org/packages/ae/11/7e500d2dd3ba891197b9efd2da5454b74336d64a7cc419aa7327ab74e5f6/cryptography-46.0.5-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:2b7a67c9cd56372f3249b39699f2ad479f6991e62ea15800973b956f4b73e257", size = 4381252, upload-time = "2026-02-10T19:18:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" }, ] [[package]] @@ -1835,7 +1835,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "(python_full_version < '3.11' and sys_platform == 'darwin') or (python_full_version < '3.11' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform == 'win32')" }, + { name = "typing-extensions", marker = "(python_full_version < '3.13' and sys_platform == 'darwin') or (python_full_version < '3.13' and sys_platform == 'linux') or (python_full_version < '3.13' and sys_platform == 'win32')" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1853,7 +1853,7 @@ wheels = [ [[package]] name = "fastapi" -version = "0.128.6" +version = "0.128.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -1862,9 +1862,9 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-inspection", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/83/d1/195005b5e45b443e305136df47ee7df4493d782e0c039dd0d97065580324/fastapi-0.128.6.tar.gz", hash = "sha256:0cb3946557e792d731b26a42b04912f16367e3c3135ea8290f620e234f2b604f", size = 374757, upload-time = "2026-02-09T17:27:03.541Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a0/fc/af386750b3fd8d8828167e4c82b787a8eeca2eca5c5429c9db8bb7c70e04/fastapi-0.128.7.tar.gz", hash = "sha256:783c273416995486c155ad2c0e2b45905dedfaf20b9ef8d9f6a9124670639a24", size = 375325, upload-time = "2026-02-10T12:26:40.968Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/24/58/a2c4f6b240eeb148fb88cdac48f50a194aba760c1ca4988c6031c66a20ee/fastapi-0.128.6-py3-none-any.whl", hash = "sha256:bb1c1ef87d6086a7132d0ab60869d6f1ee67283b20fbf84ec0003bd335099509", size = 103674, upload-time = "2026-02-09T17:27:02.355Z" }, + { url = "https://files.pythonhosted.org/packages/af/1a/f983b45661c79c31be575c570d46c437a5409b67a939c1b3d8d6b3ed7a7f/fastapi-0.128.7-py3-none-any.whl", hash = "sha256:6bd9bd31cb7047465f2d3fa3ba3f33b0870b17d4eaf7cdb36d1576ab060ad662", size = 103630, upload-time = "2026-02-10T12:26:39.414Z" }, ] [[package]] @@ -2301,7 +2301,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/fe/65/5b235b40581ad75ab97dcd8b4218022ae8e3ab77c13c919f1a1dfe9171fd/greenlet-3.3.1-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:04bee4775f40ecefcdaa9d115ab44736cd4b9c5fba733575bfe9379419582e13", size = 273723, upload-time = "2026-01-23T15:30:37.521Z" }, { url = "https://files.pythonhosted.org/packages/ce/ad/eb4729b85cba2d29499e0a04ca6fbdd8f540afd7be142fd571eea43d712f/greenlet-3.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50e1457f4fed12a50e427988a07f0f9df53cf0ee8da23fab16e6732c2ec909d4", size = 574874, upload-time = "2026-01-23T16:00:54.551Z" }, { url = "https://files.pythonhosted.org/packages/87/32/57cad7fe4c8b82fdaa098c89498ef85ad92dfbb09d5eb713adedfc2ae1f5/greenlet-3.3.1-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:070472cd156f0656f86f92e954591644e158fd65aa415ffbe2d44ca77656a8f5", size = 586309, upload-time = "2026-01-23T16:05:25.18Z" }, - { url = "https://files.pythonhosted.org/packages/66/66/f041005cb87055e62b0d68680e88ec1a57f4688523d5e2fb305841bc8307/greenlet-3.3.1-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1108b61b06b5224656121c3c8ee8876161c491cbe74e5c519e0634c837cf93d5", size = 597461, upload-time = "2026-01-23T16:15:51.943Z" }, { url = "https://files.pythonhosted.org/packages/87/eb/8a1ec2da4d55824f160594a75a9d8354a5fe0a300fb1c48e7944265217e1/greenlet-3.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3a300354f27dd86bae5fbf7002e6dd2b3255cd372e9242c933faf5e859b703fe", size = 586985, upload-time = "2026-01-23T15:32:47.968Z" }, { url = "https://files.pythonhosted.org/packages/15/1c/0621dd4321dd8c351372ee8f9308136acb628600658a49be1b7504208738/greenlet-3.3.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e84b51cbebf9ae573b5fbd15df88887815e3253fc000a7d0ff95170e8f7e9729", size = 1547271, upload-time = "2026-01-23T16:04:18.977Z" }, { url = "https://files.pythonhosted.org/packages/9d/53/24047f8924c83bea7a59c8678d9571209c6bfe5f4c17c94a78c06024e9f2/greenlet-3.3.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0093bd1a06d899892427217f0ff2a3c8f306182b8c754336d32e2d587c131b4", size = 1613427, upload-time = "2026-01-23T15:33:44.428Z" }, @@ -2309,7 +2308,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/e8/2e1462c8fdbe0f210feb5ac7ad2d9029af8be3bf45bd9fa39765f821642f/greenlet-3.3.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:5fd23b9bc6d37b563211c6abbb1b3cab27db385a4449af5c32e932f93017080c", size = 274974, upload-time = "2026-01-23T15:31:02.891Z" }, { url = "https://files.pythonhosted.org/packages/7e/a8/530a401419a6b302af59f67aaf0b9ba1015855ea7e56c036b5928793c5bd/greenlet-3.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:09f51496a0bfbaa9d74d36a52d2580d1ef5ed4fdfcff0a73730abfbbbe1403dd", size = 577175, upload-time = "2026-01-23T16:00:56.213Z" }, { url = "https://files.pythonhosted.org/packages/8e/89/7e812bb9c05e1aaef9b597ac1d0962b9021d2c6269354966451e885c4e6b/greenlet-3.3.1-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb0feb07fe6e6a74615ee62a880007d976cf739b6669cce95daa7373d4fc69c5", size = 590401, upload-time = "2026-01-23T16:05:26.365Z" }, - { url = "https://files.pythonhosted.org/packages/70/ae/e2d5f0e59b94a2269b68a629173263fa40b63da32f5c231307c349315871/greenlet-3.3.1-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:67ea3fc73c8cd92f42467a72b75e8f05ed51a0e9b1d15398c913416f2dafd49f", size = 601161, upload-time = "2026-01-23T16:15:53.456Z" }, { url = "https://files.pythonhosted.org/packages/5c/ae/8d472e1f5ac5efe55c563f3eabb38c98a44b832602e12910750a7c025802/greenlet-3.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39eda9ba259cc9801da05351eaa8576e9aa83eb9411e8f0c299e05d712a210f2", size = 590272, upload-time = "2026-01-23T15:32:49.411Z" }, { url = "https://files.pythonhosted.org/packages/a8/51/0fde34bebfcadc833550717eade64e35ec8738e6b097d5d248274a01258b/greenlet-3.3.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e2e7e882f83149f0a71ac822ebf156d902e7a5d22c9045e3e0d1daf59cee2cc9", size = 1550729, upload-time = "2026-01-23T16:04:20.867Z" }, { url = "https://files.pythonhosted.org/packages/16/c9/2fb47bee83b25b119d5a35d580807bb8b92480a54b68fef009a02945629f/greenlet-3.3.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:80aa4d79eb5564f2e0a6144fcc744b5a37c56c4a92d60920720e99210d88db0f", size = 1615552, upload-time = "2026-01-23T15:33:45.743Z" }, @@ -2318,7 +2316,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f9/c8/9d76a66421d1ae24340dfae7e79c313957f6e3195c144d2c73333b5bfe34/greenlet-3.3.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:7e806ca53acf6d15a888405880766ec84721aa4181261cd11a457dfe9a7a4975", size = 276443, upload-time = "2026-01-23T15:30:10.066Z" }, { url = "https://files.pythonhosted.org/packages/81/99/401ff34bb3c032d1f10477d199724f5e5f6fbfb59816ad1455c79c1eb8e7/greenlet-3.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d842c94b9155f1c9b3058036c24ffb8ff78b428414a19792b2380be9cecf4f36", size = 597359, upload-time = "2026-01-23T16:00:57.394Z" }, { url = "https://files.pythonhosted.org/packages/2b/bc/4dcc0871ed557792d304f50be0f7487a14e017952ec689effe2180a6ff35/greenlet-3.3.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:20fedaadd422fa02695f82093f9a98bad3dab5fcda793c658b945fcde2ab27ba", size = 607805, upload-time = "2026-01-23T16:05:28.068Z" }, - { url = "https://files.pythonhosted.org/packages/3b/cd/7a7ca57588dac3389e97f7c9521cb6641fd8b6602faf1eaa4188384757df/greenlet-3.3.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c620051669fd04ac6b60ebc70478210119c56e2d5d5df848baec4312e260e4ca", size = 622363, upload-time = "2026-01-23T16:15:54.754Z" }, { url = "https://files.pythonhosted.org/packages/cf/05/821587cf19e2ce1f2b24945d890b164401e5085f9d09cbd969b0c193cd20/greenlet-3.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14194f5f4305800ff329cbf02c5fcc88f01886cadd29941b807668a45f0d2336", size = 609947, upload-time = "2026-01-23T15:32:51.004Z" }, { url = "https://files.pythonhosted.org/packages/a4/52/ee8c46ed9f8babaa93a19e577f26e3d28a519feac6350ed6f25f1afee7e9/greenlet-3.3.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7b2fe4150a0cf59f847a67db8c155ac36aed89080a6a639e9f16df5d6c6096f1", size = 1567487, upload-time = "2026-01-23T16:04:22.125Z" }, { url = "https://files.pythonhosted.org/packages/8f/7c/456a74f07029597626f3a6db71b273a3632aecb9afafeeca452cfa633197/greenlet-3.3.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:49f4ad195d45f4a66a0eb9c1ba4832bb380570d361912fa3554746830d332149", size = 1636087, upload-time = "2026-01-23T15:33:47.486Z" }, @@ -2327,7 +2324,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/ab/d26750f2b7242c2b90ea2ad71de70cfcd73a948a49513188a0fc0d6fc15a/greenlet-3.3.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:7ab327905cabb0622adca5971e488064e35115430cec2c35a50fd36e72a315b3", size = 275205, upload-time = "2026-01-23T15:30:24.556Z" }, { url = "https://files.pythonhosted.org/packages/10/d3/be7d19e8fad7c5a78eeefb2d896a08cd4643e1e90c605c4be3b46264998f/greenlet-3.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65be2f026ca6a176f88fb935ee23c18333ccea97048076aef4db1ef5bc0713ac", size = 599284, upload-time = "2026-01-23T16:00:58.584Z" }, { url = "https://files.pythonhosted.org/packages/ae/21/fe703aaa056fdb0f17e5afd4b5c80195bbdab701208918938bd15b00d39b/greenlet-3.3.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7a3ae05b3d225b4155bda56b072ceb09d05e974bc74be6c3fc15463cf69f33fd", size = 610274, upload-time = "2026-01-23T16:05:29.312Z" }, - { url = "https://files.pythonhosted.org/packages/06/00/95df0b6a935103c0452dad2203f5be8377e551b8466a29650c4c5a5af6cc/greenlet-3.3.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:12184c61e5d64268a160226fb4818af4df02cfead8379d7f8b99a56c3a54ff3e", size = 624375, upload-time = "2026-01-23T16:15:55.915Z" }, { url = "https://files.pythonhosted.org/packages/cb/86/5c6ab23bb3c28c21ed6bebad006515cfe08b04613eb105ca0041fecca852/greenlet-3.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6423481193bbbe871313de5fd06a082f2649e7ce6e08015d2a76c1e9186ca5b3", size = 612904, upload-time = "2026-01-23T15:32:52.317Z" }, { url = "https://files.pythonhosted.org/packages/c2/f3/7949994264e22639e40718c2daf6f6df5169bf48fb038c008a489ec53a50/greenlet-3.3.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:33a956fe78bbbda82bfc95e128d61129b32d66bcf0a20a1f0c08aa4839ffa951", size = 1567316, upload-time = "2026-01-23T16:04:23.316Z" }, { url = "https://files.pythonhosted.org/packages/8d/6e/d73c94d13b6465e9f7cd6231c68abde838bb22408596c05d9059830b7872/greenlet-3.3.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4b065d3284be43728dd280f6f9a13990b56470b81be20375a207cdc814a983f2", size = 1636549, upload-time = "2026-01-23T15:33:48.643Z" }, @@ -2336,7 +2332,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ae/fb/011c7c717213182caf78084a9bea51c8590b0afda98001f69d9f853a495b/greenlet-3.3.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:bd59acd8529b372775cd0fcbc5f420ae20681c5b045ce25bd453ed8455ab99b5", size = 275737, upload-time = "2026-01-23T15:32:16.889Z" }, { url = "https://files.pythonhosted.org/packages/41/2e/a3a417d620363fdbb08a48b1dd582956a46a61bf8fd27ee8164f9dfe87c2/greenlet-3.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b31c05dd84ef6871dd47120386aed35323c944d86c3d91a17c4b8d23df62f15b", size = 646422, upload-time = "2026-01-23T16:01:00.354Z" }, { url = "https://files.pythonhosted.org/packages/b4/09/c6c4a0db47defafd2d6bab8ddfe47ad19963b4e30f5bed84d75328059f8c/greenlet-3.3.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02925a0bfffc41e542c70aa14c7eda3593e4d7e274bfcccca1827e6c0875902e", size = 658219, upload-time = "2026-01-23T16:05:30.956Z" }, - { url = "https://files.pythonhosted.org/packages/e2/89/b95f2ddcc5f3c2bc09c8ee8d77be312df7f9e7175703ab780f2014a0e781/greenlet-3.3.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3e0f3878ca3a3ff63ab4ea478585942b53df66ddde327b59ecb191b19dbbd62d", size = 671455, upload-time = "2026-01-23T16:15:57.232Z" }, { url = "https://files.pythonhosted.org/packages/80/38/9d42d60dffb04b45f03dbab9430898352dba277758640751dc5cc316c521/greenlet-3.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34a729e2e4e4ffe9ae2408d5ecaf12f944853f40ad724929b7585bca808a9d6f", size = 660237, upload-time = "2026-01-23T15:32:53.967Z" }, { url = "https://files.pythonhosted.org/packages/96/61/373c30b7197f9e756e4c81ae90a8d55dc3598c17673f91f4d31c3c689c3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:aec9ab04e82918e623415947921dea15851b152b822661cce3f8e4393c3df683", size = 1615261, upload-time = "2026-01-23T16:04:25.066Z" }, { url = "https://files.pythonhosted.org/packages/fd/d3/ca534310343f5945316f9451e953dcd89b36fe7a19de652a1dc5a0eeef3f/greenlet-3.3.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:71c767cf281a80d02b6c1bdc41c9468e1f5a494fb11bc8688c360524e273d7b1", size = 1683719, upload-time = "2026-01-23T15:33:50.61Z" }, @@ -2345,7 +2340,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/28/24/cbbec49bacdcc9ec652a81d3efef7b59f326697e7edf6ed775a5e08e54c2/greenlet-3.3.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:3e63252943c921b90abb035ebe9de832c436401d9c45f262d80e2d06cc659242", size = 282706, upload-time = "2026-01-23T15:33:05.525Z" }, { url = "https://files.pythonhosted.org/packages/86/2e/4f2b9323c144c4fe8842a4e0d92121465485c3c2c5b9e9b30a52e80f523f/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:76e39058e68eb125de10c92524573924e827927df5d3891fbc97bd55764a8774", size = 651209, upload-time = "2026-01-23T16:01:01.517Z" }, { url = "https://files.pythonhosted.org/packages/d9/87/50ca60e515f5bb55a2fbc5f0c9b5b156de7d2fc51a0a69abc9d23914a237/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9f9d5e7a9310b7a2f416dd13d2e3fd8b42d803968ea580b7c0f322ccb389b97", size = 654300, upload-time = "2026-01-23T16:05:32.199Z" }, - { url = "https://files.pythonhosted.org/packages/7c/25/c51a63f3f463171e09cb586eb64db0861eb06667ab01a7968371a24c4f3b/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4b9721549a95db96689458a1e0ae32412ca18776ed004463df3a9299c1b257ab", size = 662574, upload-time = "2026-01-23T16:15:58.364Z" }, { url = "https://files.pythonhosted.org/packages/1d/94/74310866dfa2b73dd08659a3d18762f83985ad3281901ba0ee9a815194fb/greenlet-3.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:92497c78adf3ac703b57f1e3813c2d874f27f71a178f9ea5887855da413cd6d2", size = 653842, upload-time = "2026-01-23T15:32:55.671Z" }, { url = "https://files.pythonhosted.org/packages/97/43/8bf0ffa3d498eeee4c58c212a3905dd6146c01c8dc0b0a046481ca29b18c/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ed6b402bc74d6557a705e197d47f9063733091ed6357b3de33619d8a8d93ac53", size = 1614917, upload-time = "2026-01-23T16:04:26.276Z" }, { url = "https://files.pythonhosted.org/packages/89/90/a3be7a5f378fc6e84abe4dcfb2ba32b07786861172e502388b4c90000d1b/greenlet-3.3.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:59913f1e5ada20fde795ba906916aea25d442abcc0593fba7e26c92b7ad76249", size = 1676092, upload-time = "2026-01-23T15:33:52.176Z" }, @@ -3059,7 +3053,7 @@ wheels = [ [[package]] name = "litellm" -version = "1.81.9" +version = "1.81.10" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohttp", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3075,9 +3069,9 @@ dependencies = [ { name = "tiktoken", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "tokenizers", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ff/8f/2a08f3d86fd008b4b02254649883032068378a8551baed93e8d9dcbbdb5d/litellm-1.81.9.tar.gz", hash = "sha256:a2cd9bc53a88696c21309ef37c55556f03c501392ed59d7f4250f9932917c13c", size = 16276983, upload-time = "2026-02-07T21:14:24.473Z" } +sdist = { url = "https://files.pythonhosted.org/packages/40/fc/78887158b4057835ba2c647a1bd4da650fd79142f8412c6d0bbe6d8c6081/litellm-1.81.10.tar.gz", hash = "sha256:8d769a7200888e1295592af5ce5cb0ff035832250bd0102a4ca50acf5820ca50", size = 16297572, upload-time = "2026-02-11T00:17:47.347Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/8b/672fc06c8a2803477e61e0de383d3c6e686e0f0fc62789c21f0317494076/litellm-1.81.9-py3-none-any.whl", hash = "sha256:24ee273bc8a62299fbb754035f83fb7d8d44329c383701a2bd034f4fd1c19084", size = 14433170, upload-time = "2026-02-07T21:14:21.469Z" }, + { url = "https://files.pythonhosted.org/packages/b1/bb/3f3cc3d79657bc9daaa1319ec3a9d75e4889fc88d07e327f0ac02cd2ac7d/litellm-1.81.10-py3-none-any.whl", hash = "sha256:9efa1cbe61ac051f6500c267b173d988ff2d511c2eecf1c8f2ee546c0870747c", size = 14457931, upload-time = "2026-02-11T00:17:43.431Z" }, ] [package.optional-dependencies] @@ -3119,11 +3113,11 @@ wheels = [ [[package]] name = "litellm-proxy-extras" -version = "0.4.33" +version = "0.4.34" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/51/4f/1e8644cdda2892d2dc8151153ca4d8a6fc44000363677a52f9988e56713a/litellm_proxy_extras-0.4.33.tar.gz", hash = "sha256:133dc5476b540d99e75d4baef622267e7344ced97737c174679baff429e7f212", size = 23973, upload-time = "2026-02-07T19:07:32.67Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/0f/e04f9718ddfc7a87b682e0eb98f18a5179dbe497e5d02a76ebe6aaae7269/litellm_proxy_extras-0.4.34.tar.gz", hash = "sha256:39fa6c2295acc449320b5a710d150295fd0bf5f8c0d1742b5e9ae361d7bd3ed2", size = 24232, upload-time = "2026-02-10T21:59:31.948Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b7/c0/b9960391b983306c39f1fa28e2eedf5d0e2048879fde8707a2d80896ed10/litellm_proxy_extras-0.4.33-py3-none-any.whl", hash = "sha256:bebea1b091490df19cfa773bd311f08254dee5bb53f92d282b7a5bdfba936334", size = 52533, upload-time = "2026-02-07T19:07:31.665Z" }, + { url = "https://files.pythonhosted.org/packages/21/71/a32bdfa74c598dde072d860ba1facaa522b4ef75c07c894c614999e73d75/litellm_proxy_extras-0.4.34-py3-none-any.whl", hash = "sha256:d455eb54f82e7c92f4f68a921240822df23158aad05fcdda7245887db7c30b90", size = 53171, upload-time = "2026-02-10T21:59:30.728Z" }, ] [[package]] @@ -3884,7 +3878,7 @@ wheels = [ [[package]] name = "openai" -version = "2.18.0" +version = "2.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, @@ -3896,9 +3890,9 @@ dependencies = [ { name = "tqdm", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9e/cb/f2c9f988a06d1fcdd18ddc010f43ac384219a399eb01765493d6b34b1461/openai-2.18.0.tar.gz", hash = "sha256:5018d3bcb6651c5aac90e6d0bf9da5cde1bdd23749f67b45b37c522b6e6353af", size = 632124, upload-time = "2026-02-09T21:42:18.017Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6e/5a/f495777c02625bfa18212b6e3b73f1893094f2bf660976eb4bc6f43a1ca2/openai-2.20.0.tar.gz", hash = "sha256:2654a689208cd0bf1098bb9462e8d722af5cbe961e6bba54e6f19fb843d88db1", size = 642355, upload-time = "2026-02-10T19:02:54.145Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/5f/8940e0641c223eaf972732b3154f2178a968290f8cb99e8c88582cde60ed/openai-2.18.0-py3-none-any.whl", hash = "sha256:538f97e1c77a00e3a99507688c878cda7e9e63031807ba425c68478854d48b30", size = 1069897, upload-time = "2026-02-09T21:42:16.4Z" }, + { url = "https://files.pythonhosted.org/packages/b5/a0/cf4297aa51bbc21e83ef0ac018947fa06aea8f2364aad7c96cbf148590e6/openai-2.20.0-py3-none-any.whl", hash = "sha256:38d989c4b1075cd1f76abc68364059d822327cf1a932531d429795f4fc18be99", size = 1098479, upload-time = "2026-02-10T19:02:52.157Z" }, ] [[package]] @@ -4353,100 +4347,100 @@ wheels = [ [[package]] name = "pillow" -version = "12.1.0" +version = "12.1.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d0/02/d52c733a2452ef1ffcc123b68e6606d07276b0e358db70eabad7e40042b7/pillow-12.1.0.tar.gz", hash = "sha256:5c5ae0a06e9ea030ab786b0251b32c7e4ce10e58d983c0d5c56029455180b5b9", size = 46977283, upload-time = "2026-01-02T09:13:29.892Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/42/5c74462b4fd957fcd7b13b04fb3205ff8349236ea74c7c375766d6c82288/pillow-12.1.1.tar.gz", hash = "sha256:9ad8fa5937ab05218e2b6a4cff30295ad35afd2f83ac592e68c0d871bb0fdbc4", size = 46980264, upload-time = "2026-02-11T04:23:07.146Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/41/f73d92b6b883a579e79600d391f2e21cb0df767b2714ecbd2952315dfeef/pillow-12.1.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:fb125d860738a09d363a88daa0f59c4533529a90e564785e20fe875b200b6dbd", size = 5304089, upload-time = "2026-01-02T09:10:24.953Z" }, - { url = "https://files.pythonhosted.org/packages/94/55/7aca2891560188656e4a91ed9adba305e914a4496800da6b5c0a15f09edf/pillow-12.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cad302dc10fac357d3467a74a9561c90609768a6f73a1923b0fd851b6486f8b0", size = 4657815, upload-time = "2026-01-02T09:10:27.063Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d2/b28221abaa7b4c40b7dba948f0f6a708bd7342c4d47ce342f0ea39643974/pillow-12.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a40905599d8079e09f25027423aed94f2823adaf2868940de991e53a449e14a8", size = 6222593, upload-time = "2026-01-02T09:10:29.115Z" }, - { url = "https://files.pythonhosted.org/packages/71/b8/7a61fb234df6a9b0b479f69e66901209d89ff72a435b49933f9122f94cac/pillow-12.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:92a7fe4225365c5e3a8e598982269c6d6698d3e783b3b1ae979e7819f9cd55c1", size = 8027579, upload-time = "2026-01-02T09:10:31.182Z" }, - { url = "https://files.pythonhosted.org/packages/ea/51/55c751a57cc524a15a0e3db20e5cde517582359508d62305a627e77fd295/pillow-12.1.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f10c98f49227ed8383d28174ee95155a675c4ed7f85e2e573b04414f7e371bda", size = 6335760, upload-time = "2026-01-02T09:10:33.02Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7c/60e3e6f5e5891a1a06b4c910f742ac862377a6fe842f7184df4a274ce7bf/pillow-12.1.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8637e29d13f478bc4f153d8daa9ffb16455f0a6cb287da1b432fdad2bfbd66c7", size = 7027127, upload-time = "2026-01-02T09:10:35.009Z" }, - { url = "https://files.pythonhosted.org/packages/06/37/49d47266ba50b00c27ba63a7c898f1bb41a29627ced8c09e25f19ebec0ff/pillow-12.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:21e686a21078b0f9cb8c8a961d99e6a4ddb88e0fc5ea6e130172ddddc2e5221a", size = 6449896, upload-time = "2026-01-02T09:10:36.793Z" }, - { url = "https://files.pythonhosted.org/packages/f9/e5/67fd87d2913902462cd9b79c6211c25bfe95fcf5783d06e1367d6d9a741f/pillow-12.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2415373395a831f53933c23ce051021e79c8cd7979822d8cc478547a3f4da8ef", size = 7151345, upload-time = "2026-01-02T09:10:39.064Z" }, - { url = "https://files.pythonhosted.org/packages/bd/15/f8c7abf82af68b29f50d77c227e7a1f87ce02fdc66ded9bf603bc3b41180/pillow-12.1.0-cp310-cp310-win32.whl", hash = "sha256:e75d3dba8fc1ddfec0cd752108f93b83b4f8d6ab40e524a95d35f016b9683b09", size = 6325568, upload-time = "2026-01-02T09:10:41.035Z" }, - { url = "https://files.pythonhosted.org/packages/d4/24/7d1c0e160b6b5ac2605ef7d8be537e28753c0db5363d035948073f5513d7/pillow-12.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:64efdf00c09e31efd754448a383ea241f55a994fd079866b92d2bbff598aad91", size = 7032367, upload-time = "2026-01-02T09:10:43.09Z" }, - { url = "https://files.pythonhosted.org/packages/f4/03/41c038f0d7a06099254c60f618d0ec7be11e79620fc23b8e85e5b31d9a44/pillow-12.1.0-cp310-cp310-win_arm64.whl", hash = "sha256:f188028b5af6b8fb2e9a76ac0f841a575bd1bd396e46ef0840d9b88a48fdbcea", size = 2452345, upload-time = "2026-01-02T09:10:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/43/c4/bf8328039de6cc22182c3ef007a2abfbbdab153661c0a9aa78af8d706391/pillow-12.1.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:a83e0850cb8f5ac975291ebfc4170ba481f41a28065277f7f735c202cd8e0af3", size = 5304057, upload-time = "2026-01-02T09:10:46.627Z" }, - { url = "https://files.pythonhosted.org/packages/43/06/7264c0597e676104cc22ca73ee48f752767cd4b1fe084662620b17e10120/pillow-12.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6e53e82ec2db0717eabb276aa56cf4e500c9a7cec2c2e189b55c24f65a3e8c0", size = 4657811, upload-time = "2026-01-02T09:10:49.548Z" }, - { url = "https://files.pythonhosted.org/packages/72/64/f9189e44474610daf83da31145fa56710b627b5c4c0b9c235e34058f6b31/pillow-12.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:40a8e3b9e8773876d6e30daed22f016509e3987bab61b3b7fe309d7019a87451", size = 6232243, upload-time = "2026-01-02T09:10:51.62Z" }, - { url = "https://files.pythonhosted.org/packages/ef/30/0df458009be6a4caca4ca2c52975e6275c387d4e5c95544e34138b41dc86/pillow-12.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:800429ac32c9b72909c671aaf17ecd13110f823ddb7db4dfef412a5587c2c24e", size = 8037872, upload-time = "2026-01-02T09:10:53.446Z" }, - { url = "https://files.pythonhosted.org/packages/e4/86/95845d4eda4f4f9557e25381d70876aa213560243ac1a6d619c46caaedd9/pillow-12.1.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0b022eaaf709541b391ee069f0022ee5b36c709df71986e3f7be312e46f42c84", size = 6345398, upload-time = "2026-01-02T09:10:55.426Z" }, - { url = "https://files.pythonhosted.org/packages/5c/1f/8e66ab9be3aaf1435bc03edd1ebdf58ffcd17f7349c1d970cafe87af27d9/pillow-12.1.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f345e7bc9d7f368887c712aa5054558bad44d2a301ddf9248599f4161abc7c0", size = 7034667, upload-time = "2026-01-02T09:10:57.11Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f6/683b83cb9b1db1fb52b87951b1c0b99bdcfceaa75febf11406c19f82cb5e/pillow-12.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d70347c8a5b7ccd803ec0c85c8709f036e6348f1e6a5bf048ecd9c64d3550b8b", size = 6458743, upload-time = "2026-01-02T09:10:59.331Z" }, - { url = "https://files.pythonhosted.org/packages/9a/7d/de833d63622538c1d58ce5395e7c6cb7e7dce80decdd8bde4a484e095d9f/pillow-12.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1fcc52d86ce7a34fd17cb04e87cfdb164648a3662a6f20565910a99653d66c18", size = 7159342, upload-time = "2026-01-02T09:11:01.82Z" }, - { url = "https://files.pythonhosted.org/packages/8c/40/50d86571c9e5868c42b81fe7da0c76ca26373f3b95a8dd675425f4a92ec1/pillow-12.1.0-cp311-cp311-win32.whl", hash = "sha256:3ffaa2f0659e2f740473bcf03c702c39a8d4b2b7ffc629052028764324842c64", size = 6328655, upload-time = "2026-01-02T09:11:04.556Z" }, - { url = "https://files.pythonhosted.org/packages/6c/af/b1d7e301c4cd26cd45d4af884d9ee9b6fab893b0ad2450d4746d74a6968c/pillow-12.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:806f3987ffe10e867bab0ddad45df1148a2b98221798457fa097ad85d6e8bc75", size = 7031469, upload-time = "2026-01-02T09:11:06.538Z" }, - { url = "https://files.pythonhosted.org/packages/48/36/d5716586d887fb2a810a4a61518a327a1e21c8b7134c89283af272efe84b/pillow-12.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:9f5fefaca968e700ad1a4a9de98bf0869a94e397fe3524c4c9450c1445252304", size = 2452515, upload-time = "2026-01-02T09:11:08.226Z" }, - { url = "https://files.pythonhosted.org/packages/20/31/dc53fe21a2f2996e1b7d92bf671cdb157079385183ef7c1ae08b485db510/pillow-12.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a332ac4ccb84b6dde65dbace8431f3af08874bf9770719d32a635c4ef411b18b", size = 5262642, upload-time = "2026-01-02T09:11:10.138Z" }, - { url = "https://files.pythonhosted.org/packages/ab/c1/10e45ac9cc79419cedf5121b42dcca5a50ad2b601fa080f58c22fb27626e/pillow-12.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:907bfa8a9cb790748a9aa4513e37c88c59660da3bcfffbd24a7d9e6abf224551", size = 4657464, upload-time = "2026-01-02T09:11:12.319Z" }, - { url = "https://files.pythonhosted.org/packages/ad/26/7b82c0ab7ef40ebede7a97c72d473bda5950f609f8e0c77b04af574a0ddb/pillow-12.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:efdc140e7b63b8f739d09a99033aa430accce485ff78e6d311973a67b6bf3208", size = 6234878, upload-time = "2026-01-02T09:11:14.096Z" }, - { url = "https://files.pythonhosted.org/packages/76/25/27abc9792615b5e886ca9411ba6637b675f1b77af3104710ac7353fe5605/pillow-12.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bef9768cab184e7ae6e559c032e95ba8d07b3023c289f79a2bd36e8bf85605a5", size = 8044868, upload-time = "2026-01-02T09:11:15.903Z" }, - { url = "https://files.pythonhosted.org/packages/0a/ea/f200a4c36d836100e7bc738fc48cd963d3ba6372ebc8298a889e0cfc3359/pillow-12.1.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:742aea052cf5ab5034a53c3846165bc3ce88d7c38e954120db0ab867ca242661", size = 6349468, upload-time = "2026-01-02T09:11:17.631Z" }, - { url = "https://files.pythonhosted.org/packages/11/8f/48d0b77ab2200374c66d344459b8958c86693be99526450e7aee714e03e4/pillow-12.1.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6dfc2af5b082b635af6e08e0d1f9f1c4e04d17d4e2ca0ef96131e85eda6eb17", size = 7041518, upload-time = "2026-01-02T09:11:19.389Z" }, - { url = "https://files.pythonhosted.org/packages/1d/23/c281182eb986b5d31f0a76d2a2c8cd41722d6fb8ed07521e802f9bba52de/pillow-12.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:609e89d9f90b581c8d16358c9087df76024cf058fa693dd3e1e1620823f39670", size = 6462829, upload-time = "2026-01-02T09:11:21.28Z" }, - { url = "https://files.pythonhosted.org/packages/25/ef/7018273e0faac099d7b00982abdcc39142ae6f3bd9ceb06de09779c4a9d6/pillow-12.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:43b4899cfd091a9693a1278c4982f3e50f7fb7cff5153b05174b4afc9593b616", size = 7166756, upload-time = "2026-01-02T09:11:23.559Z" }, - { url = "https://files.pythonhosted.org/packages/8f/c8/993d4b7ab2e341fe02ceef9576afcf5830cdec640be2ac5bee1820d693d4/pillow-12.1.0-cp312-cp312-win32.whl", hash = "sha256:aa0c9cc0b82b14766a99fbe6084409972266e82f459821cd26997a488a7261a7", size = 6328770, upload-time = "2026-01-02T09:11:25.661Z" }, - { url = "https://files.pythonhosted.org/packages/a7/87/90b358775a3f02765d87655237229ba64a997b87efa8ccaca7dd3e36e7a7/pillow-12.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:d70534cea9e7966169ad29a903b99fc507e932069a881d0965a1a84bb57f6c6d", size = 7033406, upload-time = "2026-01-02T09:11:27.474Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cf/881b457eccacac9e5b2ddd97d5071fb6d668307c57cbf4e3b5278e06e536/pillow-12.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:65b80c1ee7e14a87d6a068dd3b0aea268ffcabfe0498d38661b00c5b4b22e74c", size = 2452612, upload-time = "2026-01-02T09:11:29.309Z" }, - { url = "https://files.pythonhosted.org/packages/dd/c7/2530a4aa28248623e9d7f27316b42e27c32ec410f695929696f2e0e4a778/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7b5dd7cbae20285cdb597b10eb5a2c13aa9de6cde9bb64a3c1317427b1db1ae1", size = 4062543, upload-time = "2026-01-02T09:11:31.566Z" }, - { url = "https://files.pythonhosted.org/packages/8f/1f/40b8eae823dc1519b87d53c30ed9ef085506b05281d313031755c1705f73/pillow-12.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:29a4cef9cb672363926f0470afc516dbf7305a14d8c54f7abbb5c199cd8f8179", size = 4138373, upload-time = "2026-01-02T09:11:33.367Z" }, - { url = "https://files.pythonhosted.org/packages/d4/77/6fa60634cf06e52139fd0e89e5bbf055e8166c691c42fb162818b7fda31d/pillow-12.1.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:681088909d7e8fa9e31b9799aaa59ba5234c58e5e4f1951b4c4d1082a2e980e0", size = 3601241, upload-time = "2026-01-02T09:11:35.011Z" }, - { url = "https://files.pythonhosted.org/packages/4f/bf/28ab865de622e14b747f0cd7877510848252d950e43002e224fb1c9ababf/pillow-12.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:983976c2ab753166dc66d36af6e8ec15bb511e4a25856e2227e5f7e00a160587", size = 5262410, upload-time = "2026-01-02T09:11:36.682Z" }, - { url = "https://files.pythonhosted.org/packages/1c/34/583420a1b55e715937a85bd48c5c0991598247a1fd2eb5423188e765ea02/pillow-12.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:db44d5c160a90df2d24a24760bbd37607d53da0b34fb546c4c232af7192298ac", size = 4657312, upload-time = "2026-01-02T09:11:38.535Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fd/f5a0896839762885b3376ff04878f86ab2b097c2f9a9cdccf4eda8ba8dc0/pillow-12.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6b7a9d1db5dad90e2991645874f708e87d9a3c370c243c2d7684d28f7e133e6b", size = 6232605, upload-time = "2026-01-02T09:11:40.602Z" }, - { url = "https://files.pythonhosted.org/packages/98/aa/938a09d127ac1e70e6ed467bd03834350b33ef646b31edb7452d5de43792/pillow-12.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6258f3260986990ba2fa8a874f8b6e808cf5abb51a94015ca3dc3c68aa4f30ea", size = 8041617, upload-time = "2026-01-02T09:11:42.721Z" }, - { url = "https://files.pythonhosted.org/packages/17/e8/538b24cb426ac0186e03f80f78bc8dc7246c667f58b540bdd57c71c9f79d/pillow-12.1.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e115c15e3bc727b1ca3e641a909f77f8ca72a64fff150f666fcc85e57701c26c", size = 6346509, upload-time = "2026-01-02T09:11:44.955Z" }, - { url = "https://files.pythonhosted.org/packages/01/9a/632e58ec89a32738cabfd9ec418f0e9898a2b4719afc581f07c04a05e3c9/pillow-12.1.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6741e6f3074a35e47c77b23a4e4f2d90db3ed905cb1c5e6e0d49bff2045632bc", size = 7038117, upload-time = "2026-01-02T09:11:46.736Z" }, - { url = "https://files.pythonhosted.org/packages/c7/a2/d40308cf86eada842ca1f3ffa45d0ca0df7e4ab33c83f81e73f5eaed136d/pillow-12.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:935b9d1aed48fcfb3f838caac506f38e29621b44ccc4f8a64d575cb1b2a88644", size = 6460151, upload-time = "2026-01-02T09:11:48.625Z" }, - { url = "https://files.pythonhosted.org/packages/f1/88/f5b058ad6453a085c5266660a1417bdad590199da1b32fb4efcff9d33b05/pillow-12.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5fee4c04aad8932da9f8f710af2c1a15a83582cfb884152a9caa79d4efcdbf9c", size = 7164534, upload-time = "2026-01-02T09:11:50.445Z" }, - { url = "https://files.pythonhosted.org/packages/19/ce/c17334caea1db789163b5d855a5735e47995b0b5dc8745e9a3605d5f24c0/pillow-12.1.0-cp313-cp313-win32.whl", hash = "sha256:a786bf667724d84aa29b5db1c61b7bfdde380202aaca12c3461afd6b71743171", size = 6332551, upload-time = "2026-01-02T09:11:52.234Z" }, - { url = "https://files.pythonhosted.org/packages/e5/07/74a9d941fa45c90a0d9465098fe1ec85de3e2afbdc15cc4766622d516056/pillow-12.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:461f9dfdafa394c59cd6d818bdfdbab4028b83b02caadaff0ffd433faf4c9a7a", size = 7040087, upload-time = "2026-01-02T09:11:54.822Z" }, - { url = "https://files.pythonhosted.org/packages/88/09/c99950c075a0e9053d8e880595926302575bc742b1b47fe1bbcc8d388d50/pillow-12.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:9212d6b86917a2300669511ed094a9406888362e085f2431a7da985a6b124f45", size = 2452470, upload-time = "2026-01-02T09:11:56.522Z" }, - { url = "https://files.pythonhosted.org/packages/b5/ba/970b7d85ba01f348dee4d65412476321d40ee04dcb51cd3735b9dc94eb58/pillow-12.1.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:00162e9ca6d22b7c3ee8e61faa3c3253cd19b6a37f126cad04f2f88b306f557d", size = 5264816, upload-time = "2026-01-02T09:11:58.227Z" }, - { url = "https://files.pythonhosted.org/packages/10/60/650f2fb55fdba7a510d836202aa52f0baac633e50ab1cf18415d332188fb/pillow-12.1.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7d6daa89a00b58c37cb1747ec9fb7ac3bc5ffd5949f5888657dfddde6d1312e0", size = 4660472, upload-time = "2026-01-02T09:12:00.798Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/5273a99478956a099d533c4f46cbaa19fd69d606624f4334b85e50987a08/pillow-12.1.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e2479c7f02f9d505682dc47df8c0ea1fc5e264c4d1629a5d63fe3e2334b89554", size = 6268974, upload-time = "2026-01-02T09:12:02.572Z" }, - { url = "https://files.pythonhosted.org/packages/b4/26/0bf714bc2e73d5267887d47931d53c4ceeceea6978148ed2ab2a4e6463c4/pillow-12.1.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f188d580bd870cda1e15183790d1cc2fa78f666e76077d103edf048eed9c356e", size = 8073070, upload-time = "2026-01-02T09:12:04.75Z" }, - { url = "https://files.pythonhosted.org/packages/43/cf/1ea826200de111a9d65724c54f927f3111dc5ae297f294b370a670c17786/pillow-12.1.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0fde7ec5538ab5095cc02df38ee99b0443ff0e1c847a045554cf5f9af1f4aa82", size = 6380176, upload-time = "2026-01-02T09:12:06.626Z" }, - { url = "https://files.pythonhosted.org/packages/03/e0/7938dd2b2013373fd85d96e0f38d62b7a5a262af21ac274250c7ca7847c9/pillow-12.1.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0ed07dca4a8464bada6139ab38f5382f83e5f111698caf3191cb8dbf27d908b4", size = 7067061, upload-time = "2026-01-02T09:12:08.624Z" }, - { url = "https://files.pythonhosted.org/packages/86/ad/a2aa97d37272a929a98437a8c0ac37b3cf012f4f8721e1bd5154699b2518/pillow-12.1.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f45bd71d1fa5e5749587613037b172e0b3b23159d1c00ef2fc920da6f470e6f0", size = 6491824, upload-time = "2026-01-02T09:12:10.488Z" }, - { url = "https://files.pythonhosted.org/packages/a4/44/80e46611b288d51b115826f136fb3465653c28f491068a72d3da49b54cd4/pillow-12.1.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:277518bf4fe74aa91489e1b20577473b19ee70fb97c374aa50830b279f25841b", size = 7190911, upload-time = "2026-01-02T09:12:12.772Z" }, - { url = "https://files.pythonhosted.org/packages/86/77/eacc62356b4cf81abe99ff9dbc7402750044aed02cfd6a503f7c6fc11f3e/pillow-12.1.0-cp313-cp313t-win32.whl", hash = "sha256:7315f9137087c4e0ee73a761b163fc9aa3b19f5f606a7fc08d83fd3e4379af65", size = 6336445, upload-time = "2026-01-02T09:12:14.775Z" }, - { url = "https://files.pythonhosted.org/packages/e7/3c/57d81d0b74d218706dafccb87a87ea44262c43eef98eb3b164fd000e0491/pillow-12.1.0-cp313-cp313t-win_amd64.whl", hash = "sha256:0ddedfaa8b5f0b4ffbc2fa87b556dc59f6bb4ecb14a53b33f9189713ae8053c0", size = 7045354, upload-time = "2026-01-02T09:12:16.599Z" }, - { url = "https://files.pythonhosted.org/packages/ac/82/8b9b97bba2e3576a340f93b044a3a3a09841170ab4c1eb0d5c93469fd32f/pillow-12.1.0-cp313-cp313t-win_arm64.whl", hash = "sha256:80941e6d573197a0c28f394753de529bb436b1ca990ed6e765cf42426abc39f8", size = 2454547, upload-time = "2026-01-02T09:12:18.704Z" }, - { url = "https://files.pythonhosted.org/packages/8c/87/bdf971d8bbcf80a348cc3bacfcb239f5882100fe80534b0ce67a784181d8/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:5cb7bc1966d031aec37ddb9dcf15c2da5b2e9f7cc3ca7c54473a20a927e1eb91", size = 4062533, upload-time = "2026-01-02T09:12:20.791Z" }, - { url = "https://files.pythonhosted.org/packages/ff/4f/5eb37a681c68d605eb7034c004875c81f86ec9ef51f5be4a63eadd58859a/pillow-12.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:97e9993d5ed946aba26baf9c1e8cf18adbab584b99f452ee72f7ee8acb882796", size = 4138546, upload-time = "2026-01-02T09:12:23.664Z" }, - { url = "https://files.pythonhosted.org/packages/11/6d/19a95acb2edbace40dcd582d077b991646b7083c41b98da4ed7555b59733/pillow-12.1.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:414b9a78e14ffeb98128863314e62c3f24b8a86081066625700b7985b3f529bd", size = 3601163, upload-time = "2026-01-02T09:12:26.338Z" }, - { url = "https://files.pythonhosted.org/packages/fc/36/2b8138e51cb42e4cc39c3297713455548be855a50558c3ac2beebdc251dd/pillow-12.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:e6bdb408f7c9dd2a5ff2b14a3b0bb6d4deb29fb9961e6eb3ae2031ae9a5cec13", size = 5266086, upload-time = "2026-01-02T09:12:28.782Z" }, - { url = "https://files.pythonhosted.org/packages/53/4b/649056e4d22e1caa90816bf99cef0884aed607ed38075bd75f091a607a38/pillow-12.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3413c2ae377550f5487991d444428f1a8ae92784aac79caa8b1e3b89b175f77e", size = 4657344, upload-time = "2026-01-02T09:12:31.117Z" }, - { url = "https://files.pythonhosted.org/packages/6c/6b/c5742cea0f1ade0cd61485dc3d81f05261fc2276f537fbdc00802de56779/pillow-12.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e5dcbe95016e88437ecf33544ba5db21ef1b8dd6e1b434a2cb2a3d605299e643", size = 6232114, upload-time = "2026-01-02T09:12:32.936Z" }, - { url = "https://files.pythonhosted.org/packages/bf/8f/9f521268ce22d63991601aafd3d48d5ff7280a246a1ef62d626d67b44064/pillow-12.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d0a7735df32ccbcc98b98a1ac785cc4b19b580be1bdf0aeb5c03223220ea09d5", size = 8042708, upload-time = "2026-01-02T09:12:34.78Z" }, - { url = "https://files.pythonhosted.org/packages/1a/eb/257f38542893f021502a1bbe0c2e883c90b5cff26cc33b1584a841a06d30/pillow-12.1.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c27407a2d1b96774cbc4a7594129cc027339fd800cd081e44497722ea1179de", size = 6347762, upload-time = "2026-01-02T09:12:36.748Z" }, - { url = "https://files.pythonhosted.org/packages/c4/5a/8ba375025701c09b309e8d5163c5a4ce0102fa86bbf8800eb0d7ac87bc51/pillow-12.1.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15c794d74303828eaa957ff8070846d0efe8c630901a1c753fdc63850e19ecd9", size = 7039265, upload-time = "2026-01-02T09:12:39.082Z" }, - { url = "https://files.pythonhosted.org/packages/cf/dc/cf5e4cdb3db533f539e88a7bbf9f190c64ab8a08a9bc7a4ccf55067872e4/pillow-12.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c990547452ee2800d8506c4150280757f88532f3de2a58e3022e9b179107862a", size = 6462341, upload-time = "2026-01-02T09:12:40.946Z" }, - { url = "https://files.pythonhosted.org/packages/d0/47/0291a25ac9550677e22eda48510cfc4fa4b2ef0396448b7fbdc0a6946309/pillow-12.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b63e13dd27da389ed9475b3d28510f0f954bca0041e8e551b2a4eb1eab56a39a", size = 7165395, upload-time = "2026-01-02T09:12:42.706Z" }, - { url = "https://files.pythonhosted.org/packages/4f/4c/e005a59393ec4d9416be06e6b45820403bb946a778e39ecec62f5b2b991e/pillow-12.1.0-cp314-cp314-win32.whl", hash = "sha256:1a949604f73eb07a8adab38c4fe50791f9919344398bdc8ac6b307f755fc7030", size = 6431413, upload-time = "2026-01-02T09:12:44.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/af/f23697f587ac5f9095d67e31b81c95c0249cd461a9798a061ed6709b09b5/pillow-12.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:4f9f6a650743f0ddee5593ac9e954ba1bdbc5e150bc066586d4f26127853ab94", size = 7176779, upload-time = "2026-01-02T09:12:46.727Z" }, - { url = "https://files.pythonhosted.org/packages/b3/36/6a51abf8599232f3e9afbd16d52829376a68909fe14efe29084445db4b73/pillow-12.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:808b99604f7873c800c4840f55ff389936ef1948e4e87645eaf3fccbc8477ac4", size = 2543105, upload-time = "2026-01-02T09:12:49.243Z" }, - { url = "https://files.pythonhosted.org/packages/82/54/2e1dd20c8749ff225080d6ba465a0cab4387f5db0d1c5fb1439e2d99923f/pillow-12.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc11908616c8a283cf7d664f77411a5ed2a02009b0097ff8abbba5e79128ccf2", size = 5268571, upload-time = "2026-01-02T09:12:51.11Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/571163a5ef86ec0cf30d265ac2a70ae6fc9e28413d1dc94fa37fae6bda89/pillow-12.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:896866d2d436563fa2a43a9d72f417874f16b5545955c54a64941e87c1376c61", size = 4660426, upload-time = "2026-01-02T09:12:52.865Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e1/53ee5163f794aef1bf84243f755ee6897a92c708505350dd1923f4afec48/pillow-12.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8e178e3e99d3c0ea8fc64b88447f7cac8ccf058af422a6cedc690d0eadd98c51", size = 6269908, upload-time = "2026-01-02T09:12:54.884Z" }, - { url = "https://files.pythonhosted.org/packages/bc/0b/b4b4106ff0ee1afa1dc599fde6ab230417f800279745124f6c50bcffed8e/pillow-12.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:079af2fb0c599c2ec144ba2c02766d1b55498e373b3ac64687e43849fbbef5bc", size = 8074733, upload-time = "2026-01-02T09:12:56.802Z" }, - { url = "https://files.pythonhosted.org/packages/19/9f/80b411cbac4a732439e629a26ad3ef11907a8c7fc5377b7602f04f6fe4e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bdec5e43377761c5dbca620efb69a77f6855c5a379e32ac5b158f54c84212b14", size = 6381431, upload-time = "2026-01-02T09:12:58.823Z" }, - { url = "https://files.pythonhosted.org/packages/8f/b7/d65c45db463b66ecb6abc17c6ba6917a911202a07662247e1355ce1789e7/pillow-12.1.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:565c986f4b45c020f5421a4cea13ef294dde9509a8577f29b2fc5edc7587fff8", size = 7068529, upload-time = "2026-01-02T09:13:00.885Z" }, - { url = "https://files.pythonhosted.org/packages/50/96/dfd4cd726b4a45ae6e3c669fc9e49deb2241312605d33aba50499e9d9bd1/pillow-12.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:43aca0a55ce1eefc0aefa6253661cb54571857b1a7b2964bd8a1e3ef4b729924", size = 6492981, upload-time = "2026-01-02T09:13:03.314Z" }, - { url = "https://files.pythonhosted.org/packages/4d/1c/b5dc52cf713ae46033359c5ca920444f18a6359ce1020dd3e9c553ea5bc6/pillow-12.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0deedf2ea233722476b3a81e8cdfbad786f7adbed5d848469fa59fe52396e4ef", size = 7191878, upload-time = "2026-01-02T09:13:05.276Z" }, - { url = "https://files.pythonhosted.org/packages/53/26/c4188248bd5edaf543864fe4834aebe9c9cb4968b6f573ce014cc42d0720/pillow-12.1.0-cp314-cp314t-win32.whl", hash = "sha256:b17fbdbe01c196e7e159aacb889e091f28e61020a8abeac07b68079b6e626988", size = 6438703, upload-time = "2026-01-02T09:13:07.491Z" }, - { url = "https://files.pythonhosted.org/packages/b8/0e/69ed296de8ea05cb03ee139cee600f424ca166e632567b2d66727f08c7ed/pillow-12.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27b9baecb428899db6c0de572d6d305cfaf38ca1596b5c0542a5182e3e74e8c6", size = 7182927, upload-time = "2026-01-02T09:13:09.841Z" }, - { url = "https://files.pythonhosted.org/packages/fc/f5/68334c015eed9b5cff77814258717dec591ded209ab5b6fb70e2ae873d1d/pillow-12.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f61333d817698bdcdd0f9d7793e365ac3d2a21c1f1eb02b32ad6aefb8d8ea831", size = 2545104, upload-time = "2026-01-02T09:13:12.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/bc/224b1d98cffd7164b14707c91aac83c07b047fbd8f58eba4066a3e53746a/pillow-12.1.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ca94b6aac0d7af2a10ba08c0f888b3d5114439b6b3ef39968378723622fed377", size = 5228605, upload-time = "2026-01-02T09:13:14.084Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ca/49ca7769c4550107de049ed85208240ba0f330b3f2e316f24534795702ce/pillow-12.1.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:351889afef0f485b84078ea40fe33727a0492b9af3904661b0abbafee0355b72", size = 4622245, upload-time = "2026-01-02T09:13:15.964Z" }, - { url = "https://files.pythonhosted.org/packages/73/48/fac807ce82e5955bcc2718642b94b1bd22a82a6d452aea31cbb678cddf12/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb0984b30e973f7e2884362b7d23d0a348c7143ee559f38ef3eaab640144204c", size = 5247593, upload-time = "2026-01-02T09:13:17.913Z" }, - { url = "https://files.pythonhosted.org/packages/d2/95/3e0742fe358c4664aed4fd05d5f5373dcdad0b27af52aa0972568541e3f4/pillow-12.1.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:84cabc7095dd535ca934d57e9ce2a72ffd216e435a84acb06b2277b1de2689bd", size = 6989008, upload-time = "2026-01-02T09:13:20.083Z" }, - { url = "https://files.pythonhosted.org/packages/5a/74/fe2ac378e4e202e56d50540d92e1ef4ff34ed687f3c60f6a121bcf99437e/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:53d8b764726d3af1a138dd353116f774e3862ec7e3794e0c8781e30db0f35dfc", size = 5313824, upload-time = "2026-01-02T09:13:22.405Z" }, - { url = "https://files.pythonhosted.org/packages/f3/77/2a60dee1adee4e2655ac328dd05c02a955c1cd683b9f1b82ec3feb44727c/pillow-12.1.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5da841d81b1a05ef940a8567da92decaa15bc4d7dedb540a8c219ad83d91808a", size = 5963278, upload-time = "2026-01-02T09:13:24.706Z" }, - { url = "https://files.pythonhosted.org/packages/2d/71/64e9b1c7f04ae0027f788a248e6297d7fcc29571371fe7d45495a78172c0/pillow-12.1.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:75af0b4c229ac519b155028fa1be632d812a519abba9b46b20e50c6caa184f19", size = 7029809, upload-time = "2026-01-02T09:13:26.541Z" }, + { url = "https://files.pythonhosted.org/packages/1d/30/5bd3d794762481f8c8ae9c80e7b76ecea73b916959eb587521358ef0b2f9/pillow-12.1.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1f1625b72740fdda5d77b4def688eb8fd6490975d06b909fd19f13f391e077e0", size = 5304099, upload-time = "2026-02-11T04:20:06.13Z" }, + { url = "https://files.pythonhosted.org/packages/bd/c1/aab9e8f3eeb4490180e357955e15c2ef74b31f64790ff356c06fb6cf6d84/pillow-12.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:178aa072084bd88ec759052feca8e56cbb14a60b39322b99a049e58090479713", size = 4657880, upload-time = "2026-02-11T04:20:09.291Z" }, + { url = "https://files.pythonhosted.org/packages/f1/0a/9879e30d56815ad529d3985aeff5af4964202425c27261a6ada10f7cbf53/pillow-12.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b66e95d05ba806247aaa1561f080abc7975daf715c30780ff92a20e4ec546e1b", size = 6222587, upload-time = "2026-02-11T04:20:10.82Z" }, + { url = "https://files.pythonhosted.org/packages/5a/5f/a1b72ff7139e4f89014e8d451442c74a774d5c43cd938fb0a9f878576b37/pillow-12.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:89c7e895002bbe49cdc5426150377cbbc04767d7547ed145473f496dfa40408b", size = 8027678, upload-time = "2026-02-11T04:20:12.455Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c2/c7cb187dac79a3d22c3ebeae727abee01e077c8c7d930791dc592f335153/pillow-12.1.1-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a5cbdcddad0af3da87cb16b60d23648bc3b51967eb07223e9fed77a82b457c4", size = 6335777, upload-time = "2026-02-11T04:20:14.441Z" }, + { url = "https://files.pythonhosted.org/packages/0c/7b/f9b09a7804ec7336effb96c26d37c29d27225783dc1501b7d62dcef6ae25/pillow-12.1.1-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9f51079765661884a486727f0729d29054242f74b46186026582b4e4769918e4", size = 7027140, upload-time = "2026-02-11T04:20:16.387Z" }, + { url = "https://files.pythonhosted.org/packages/98/b2/2fa3c391550bd421b10849d1a2144c44abcd966daadd2f7c12e19ea988c4/pillow-12.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:99c1506ea77c11531d75e3a412832a13a71c7ebc8192ab9e4b2e355555920e3e", size = 6449855, upload-time = "2026-02-11T04:20:18.554Z" }, + { url = "https://files.pythonhosted.org/packages/96/ff/9caf4b5b950c669263c39e96c78c0d74a342c71c4f43fd031bb5cb7ceac9/pillow-12.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:36341d06738a9f66c8287cf8b876d24b18db9bd8740fa0672c74e259ad408cff", size = 7151329, upload-time = "2026-02-11T04:20:20.646Z" }, + { url = "https://files.pythonhosted.org/packages/7b/f8/4b24841f582704da675ca535935bccb32b00a6da1226820845fac4a71136/pillow-12.1.1-cp310-cp310-win32.whl", hash = "sha256:6c52f062424c523d6c4db85518774cc3d50f5539dd6eed32b8f6229b26f24d40", size = 6325574, upload-time = "2026-02-11T04:20:22.43Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f9/9f6b01c0881d7036063aa6612ef04c0e2cad96be21325a1e92d0203f8e91/pillow-12.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6008de247150668a705a6338156efb92334113421ceecf7438a12c9a12dab23", size = 7032347, upload-time = "2026-02-11T04:20:23.932Z" }, + { url = "https://files.pythonhosted.org/packages/79/13/c7922edded3dcdaf10c59297540b72785620abc0538872c819915746757d/pillow-12.1.1-cp310-cp310-win_arm64.whl", hash = "sha256:1a9b0ee305220b392e1124a764ee4265bd063e54a751a6b62eff69992f457fa9", size = 2453457, upload-time = "2026-02-11T04:20:25.392Z" }, + { url = "https://files.pythonhosted.org/packages/2b/46/5da1ec4a5171ee7bf1a0efa064aba70ba3d6e0788ce3f5acd1375d23c8c0/pillow-12.1.1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:e879bb6cd5c73848ef3b2b48b8af9ff08c5b71ecda8048b7dd22d8a33f60be32", size = 5304084, upload-time = "2026-02-11T04:20:27.501Z" }, + { url = "https://files.pythonhosted.org/packages/78/93/a29e9bc02d1cf557a834da780ceccd54e02421627200696fcf805ebdc3fb/pillow-12.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:365b10bb9417dd4498c0e3b128018c4a624dc11c7b97d8cc54effe3b096f4c38", size = 4657866, upload-time = "2026-02-11T04:20:29.827Z" }, + { url = "https://files.pythonhosted.org/packages/13/84/583a4558d492a179d31e4aae32eadce94b9acf49c0337c4ce0b70e0a01f2/pillow-12.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d4ce8e329c93845720cd2014659ca67eac35f6433fd3050393d85f3ecef0dad5", size = 6232148, upload-time = "2026-02-11T04:20:31.329Z" }, + { url = "https://files.pythonhosted.org/packages/d5/e2/53c43334bbbb2d3b938978532fbda8e62bb6e0b23a26ce8592f36bcc4987/pillow-12.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc354a04072b765eccf2204f588a7a532c9511e8b9c7f900e1b64e3e33487090", size = 8038007, upload-time = "2026-02-11T04:20:34.225Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a6/3d0e79c8a9d58150dd98e199d7c1c56861027f3829a3a60b3c2784190180/pillow-12.1.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e7976bf1910a8116b523b9f9f58bf410f3e8aa330cd9a2bb2953f9266ab49af", size = 6345418, upload-time = "2026-02-11T04:20:35.858Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c8/46dfeac5825e600579157eea177be43e2f7ff4a99da9d0d0a49533509ac5/pillow-12.1.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:597bd9c8419bc7c6af5604e55847789b69123bbe25d65cc6ad3012b4f3c98d8b", size = 7034590, upload-time = "2026-02-11T04:20:37.91Z" }, + { url = "https://files.pythonhosted.org/packages/af/bf/e6f65d3db8a8bbfeaf9e13cc0417813f6319863a73de934f14b2229ada18/pillow-12.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c1fc0f2ca5f96a3c8407e41cca26a16e46b21060fe6d5b099d2cb01412222f5", size = 6458655, upload-time = "2026-02-11T04:20:39.496Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c2/66091f3f34a25894ca129362e510b956ef26f8fb67a0e6417bc5744e56f1/pillow-12.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:578510d88c6229d735855e1f278aa305270438d36a05031dfaae5067cc8eb04d", size = 7159286, upload-time = "2026-02-11T04:20:41.139Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5a/24bc8eb526a22f957d0cec6243146744966d40857e3d8deb68f7902ca6c1/pillow-12.1.1-cp311-cp311-win32.whl", hash = "sha256:7311c0a0dcadb89b36b7025dfd8326ecfa36964e29913074d47382706e516a7c", size = 6328663, upload-time = "2026-02-11T04:20:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/31/03/bef822e4f2d8f9d7448c133d0a18185d3cce3e70472774fffefe8b0ed562/pillow-12.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:fbfa2a7c10cc2623f412753cddf391c7f971c52ca40a3f65dc5039b2939e8563", size = 7031448, upload-time = "2026-02-11T04:20:44.696Z" }, + { url = "https://files.pythonhosted.org/packages/49/70/f76296f53610bd17b2e7d31728b8b7825e3ac3b5b3688b51f52eab7c0818/pillow-12.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:b81b5e3511211631b3f672a595e3221252c90af017e399056d0faabb9538aa80", size = 2453651, upload-time = "2026-02-11T04:20:46.243Z" }, + { url = "https://files.pythonhosted.org/packages/07/d3/8df65da0d4df36b094351dce696f2989bec731d4f10e743b1c5f4da4d3bf/pillow-12.1.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab323b787d6e18b3d91a72fc99b1a2c28651e4358749842b8f8dfacd28ef2052", size = 5262803, upload-time = "2026-02-11T04:20:47.653Z" }, + { url = "https://files.pythonhosted.org/packages/d6/71/5026395b290ff404b836e636f51d7297e6c83beceaa87c592718747e670f/pillow-12.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:adebb5bee0f0af4909c30db0d890c773d1a92ffe83da908e2e9e720f8edf3984", size = 4657601, upload-time = "2026-02-11T04:20:49.328Z" }, + { url = "https://files.pythonhosted.org/packages/b1/2e/1001613d941c67442f745aff0f7cc66dd8df9a9c084eb497e6a543ee6f7e/pillow-12.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bb66b7cc26f50977108790e2456b7921e773f23db5630261102233eb355a3b79", size = 6234995, upload-time = "2026-02-11T04:20:51.032Z" }, + { url = "https://files.pythonhosted.org/packages/07/26/246ab11455b2549b9233dbd44d358d033a2f780fa9007b61a913c5b2d24e/pillow-12.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee2810642b2898bb187ced9b349e95d2a7272930796e022efaf12e99dccd293", size = 8045012, upload-time = "2026-02-11T04:20:52.882Z" }, + { url = "https://files.pythonhosted.org/packages/b2/8b/07587069c27be7535ac1fe33874e32de118fbd34e2a73b7f83436a88368c/pillow-12.1.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a0b1cd6232e2b618adcc54d9882e4e662a089d5768cd188f7c245b4c8c44a397", size = 6349638, upload-time = "2026-02-11T04:20:54.444Z" }, + { url = "https://files.pythonhosted.org/packages/ff/79/6df7b2ee763d619cda2fb4fea498e5f79d984dae304d45a8999b80d6cf5c/pillow-12.1.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7aac39bcf8d4770d089588a2e1dd111cbaa42df5a94be3114222057d68336bd0", size = 7041540, upload-time = "2026-02-11T04:20:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2c/5e/2ba19e7e7236d7529f4d873bdaf317a318896bac289abebd4bb00ef247f0/pillow-12.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ab174cd7d29a62dd139c44bf74b698039328f45cb03b4596c43473a46656b2f3", size = 6462613, upload-time = "2026-02-11T04:20:57.542Z" }, + { url = "https://files.pythonhosted.org/packages/03/03/31216ec124bb5c3dacd74ce8efff4cc7f52643653bad4825f8f08c697743/pillow-12.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:339ffdcb7cbeaa08221cd401d517d4b1fe7a9ed5d400e4a8039719238620ca35", size = 7166745, upload-time = "2026-02-11T04:20:59.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e7/7c4552d80052337eb28653b617eafdef39adfb137c49dd7e831b8dc13bc5/pillow-12.1.1-cp312-cp312-win32.whl", hash = "sha256:5d1f9575a12bed9e9eedd9a4972834b08c97a352bd17955ccdebfeca5913fa0a", size = 6328823, upload-time = "2026-02-11T04:21:01.385Z" }, + { url = "https://files.pythonhosted.org/packages/3d/17/688626d192d7261bbbf98846fc98995726bddc2c945344b65bec3a29d731/pillow-12.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:21329ec8c96c6e979cd0dfd29406c40c1d52521a90544463057d2aaa937d66a6", size = 7033367, upload-time = "2026-02-11T04:21:03.536Z" }, + { url = "https://files.pythonhosted.org/packages/ed/fe/a0ef1f73f939b0eca03ee2c108d0043a87468664770612602c63266a43c4/pillow-12.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:af9a332e572978f0218686636610555ae3defd1633597be015ed50289a03c523", size = 2453811, upload-time = "2026-02-11T04:21:05.116Z" }, + { url = "https://files.pythonhosted.org/packages/d5/11/6db24d4bd7685583caeae54b7009584e38da3c3d4488ed4cd25b439de486/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d242e8ac078781f1de88bf823d70c1a9b3c7950a44cdf4b7c012e22ccbcd8e4e", size = 4062689, upload-time = "2026-02-11T04:21:06.804Z" }, + { url = "https://files.pythonhosted.org/packages/33/c0/ce6d3b1fe190f0021203e0d9b5b99e57843e345f15f9ef22fcd43842fd21/pillow-12.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:02f84dfad02693676692746df05b89cf25597560db2857363a208e393429f5e9", size = 4138535, upload-time = "2026-02-11T04:21:08.452Z" }, + { url = "https://files.pythonhosted.org/packages/a0/c6/d5eb6a4fb32a3f9c21a8c7613ec706534ea1cf9f4b3663e99f0d83f6fca8/pillow-12.1.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:e65498daf4b583091ccbb2556c7000abf0f3349fcd57ef7adc9a84a394ed29f6", size = 3601364, upload-time = "2026-02-11T04:21:10.194Z" }, + { url = "https://files.pythonhosted.org/packages/14/a1/16c4b823838ba4c9c52c0e6bbda903a3fe5a1bdbf1b8eb4fff7156f3e318/pillow-12.1.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6c6db3b84c87d48d0088943bf33440e0c42370b99b1c2a7989216f7b42eede60", size = 5262561, upload-time = "2026-02-11T04:21:11.742Z" }, + { url = "https://files.pythonhosted.org/packages/bb/ad/ad9dc98ff24f485008aa5cdedaf1a219876f6f6c42a4626c08bc4e80b120/pillow-12.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8b7e5304e34942bf62e15184219a7b5ad4ff7f3bb5cca4d984f37df1a0e1aee2", size = 4657460, upload-time = "2026-02-11T04:21:13.786Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f1a4ea9a895b5732152789326202a82464d5254759fbacae4deea3069334/pillow-12.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:18e5bddd742a44b7e6b1e773ab5db102bd7a94c32555ba656e76d319d19c3850", size = 6232698, upload-time = "2026-02-11T04:21:15.949Z" }, + { url = "https://files.pythonhosted.org/packages/95/f4/86f51b8745070daf21fd2e5b1fe0eb35d4db9ca26e6d58366562fb56a743/pillow-12.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc44ef1f3de4f45b50ccf9136999d71abb99dca7706bc75d222ed350b9fd2289", size = 8041706, upload-time = "2026-02-11T04:21:17.723Z" }, + { url = "https://files.pythonhosted.org/packages/29/9b/d6ecd956bb1266dd1045e995cce9b8d77759e740953a1c9aad9502a0461e/pillow-12.1.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a8eb7ed8d4198bccbd07058416eeec51686b498e784eda166395a23eb99138e", size = 6346621, upload-time = "2026-02-11T04:21:19.547Z" }, + { url = "https://files.pythonhosted.org/packages/71/24/538bff45bde96535d7d998c6fed1a751c75ac7c53c37c90dc2601b243893/pillow-12.1.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47b94983da0c642de92ced1702c5b6c292a84bd3a8e1d1702ff923f183594717", size = 7038069, upload-time = "2026-02-11T04:21:21.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/0e/58cb1a6bc48f746bc4cb3adb8cabff73e2742c92b3bf7a220b7cf69b9177/pillow-12.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:518a48c2aab7ce596d3bf79d0e275661b846e86e4d0e7dec34712c30fe07f02a", size = 6460040, upload-time = "2026-02-11T04:21:23.148Z" }, + { url = "https://files.pythonhosted.org/packages/6c/57/9045cb3ff11eeb6c1adce3b2d60d7d299d7b273a2e6c8381a524abfdc474/pillow-12.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a550ae29b95c6dc13cf69e2c9dc5747f814c54eeb2e32d683e5e93af56caa029", size = 7164523, upload-time = "2026-02-11T04:21:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/73/f2/9be9cb99f2175f0d4dbadd6616ce1bf068ee54a28277ea1bf1fbf729c250/pillow-12.1.1-cp313-cp313-win32.whl", hash = "sha256:a003d7422449f6d1e3a34e3dd4110c22148336918ddbfc6a32581cd54b2e0b2b", size = 6332552, upload-time = "2026-02-11T04:21:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/3f/eb/b0834ad8b583d7d9d42b80becff092082a1c3c156bb582590fcc973f1c7c/pillow-12.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:344cf1e3dab3be4b1fa08e449323d98a2a3f819ad20f4b22e77a0ede31f0faa1", size = 7040108, upload-time = "2026-02-11T04:21:29.462Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/fc09634e2aabdd0feabaff4a32f4a7d97789223e7c2042fd805ea4b4d2c2/pillow-12.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:5c0dd1636633e7e6a0afe7bf6a51a14992b7f8e60de5789018ebbdfae55b040a", size = 2453712, upload-time = "2026-02-11T04:21:31.072Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/b9d62794fc8a0dd14c1943df68347badbd5511103e0d04c035ffe5cf2255/pillow-12.1.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0330d233c1a0ead844fc097a7d16c0abff4c12e856c0b325f231820fee1f39da", size = 5264880, upload-time = "2026-02-11T04:21:32.865Z" }, + { url = "https://files.pythonhosted.org/packages/26/9d/e03d857d1347fa5ed9247e123fcd2a97b6220e15e9cb73ca0a8d91702c6e/pillow-12.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5dae5f21afb91322f2ff791895ddd8889e5e947ff59f71b46041c8ce6db790bc", size = 4660616, upload-time = "2026-02-11T04:21:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ec/8a6d22afd02570d30954e043f09c32772bfe143ba9285e2fdb11284952cd/pillow-12.1.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2e0c664be47252947d870ac0d327fea7e63985a08794758aa8af5b6cb6ec0c9c", size = 6269008, upload-time = "2026-02-11T04:21:36.623Z" }, + { url = "https://files.pythonhosted.org/packages/3d/1d/6d875422c9f28a4a361f495a5f68d9de4a66941dc2c619103ca335fa6446/pillow-12.1.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:691ab2ac363b8217f7d31b3497108fb1f50faab2f75dfb03284ec2f217e87bf8", size = 8073226, upload-time = "2026-02-11T04:21:38.585Z" }, + { url = "https://files.pythonhosted.org/packages/a1/cd/134b0b6ee5eda6dc09e25e24b40fdafe11a520bc725c1d0bbaa5e00bf95b/pillow-12.1.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9e8064fb1cc019296958595f6db671fba95209e3ceb0c4734c9baf97de04b20", size = 6380136, upload-time = "2026-02-11T04:21:40.562Z" }, + { url = "https://files.pythonhosted.org/packages/7a/a9/7628f013f18f001c1b98d8fffe3452f306a70dc6aba7d931019e0492f45e/pillow-12.1.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:472a8d7ded663e6162dafdf20015c486a7009483ca671cece7a9279b512fcb13", size = 7067129, upload-time = "2026-02-11T04:21:42.521Z" }, + { url = "https://files.pythonhosted.org/packages/1e/f8/66ab30a2193b277785601e82ee2d49f68ea575d9637e5e234faaa98efa4c/pillow-12.1.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:89b54027a766529136a06cfebeecb3a04900397a3590fd252160b888479517bf", size = 6491807, upload-time = "2026-02-11T04:21:44.22Z" }, + { url = "https://files.pythonhosted.org/packages/da/0b/a877a6627dc8318fdb84e357c5e1a758c0941ab1ddffdafd231983788579/pillow-12.1.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:86172b0831b82ce4f7877f280055892b31179e1576aa00d0df3bb1bbf8c3e524", size = 7190954, upload-time = "2026-02-11T04:21:46.114Z" }, + { url = "https://files.pythonhosted.org/packages/83/43/6f732ff85743cf746b1361b91665d9f5155e1483817f693f8d57ea93147f/pillow-12.1.1-cp313-cp313t-win32.whl", hash = "sha256:44ce27545b6efcf0fdbdceb31c9a5bdea9333e664cda58a7e674bb74608b3986", size = 6336441, upload-time = "2026-02-11T04:21:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/3b/44/e865ef3986611bb75bfabdf94a590016ea327833f434558801122979cd0e/pillow-12.1.1-cp313-cp313t-win_amd64.whl", hash = "sha256:a285e3eb7a5a45a2ff504e31f4a8d1b12ef62e84e5411c6804a42197c1cf586c", size = 7045383, upload-time = "2026-02-11T04:21:50.015Z" }, + { url = "https://files.pythonhosted.org/packages/a8/c6/f4fb24268d0c6908b9f04143697ea18b0379490cb74ba9e8d41b898bd005/pillow-12.1.1-cp313-cp313t-win_arm64.whl", hash = "sha256:cc7d296b5ea4d29e6570dabeaed58d31c3fea35a633a69679fb03d7664f43fb3", size = 2456104, upload-time = "2026-02-11T04:21:51.633Z" }, + { url = "https://files.pythonhosted.org/packages/03/d0/bebb3ffbf31c5a8e97241476c4cf8b9828954693ce6744b4a2326af3e16b/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:417423db963cb4be8bac3fc1204fe61610f6abeed1580a7a2cbb2fbda20f12af", size = 4062652, upload-time = "2026-02-11T04:21:53.19Z" }, + { url = "https://files.pythonhosted.org/packages/2d/c0/0e16fb0addda4851445c28f8350d8c512f09de27bbb0d6d0bbf8b6709605/pillow-12.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:b957b71c6b2387610f556a7eb0828afbe40b4a98036fc0d2acfa5a44a0c2036f", size = 4138823, upload-time = "2026-02-11T04:22:03.088Z" }, + { url = "https://files.pythonhosted.org/packages/6b/fb/6170ec655d6f6bb6630a013dd7cf7bc218423d7b5fa9071bf63dc32175ae/pillow-12.1.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:097690ba1f2efdeb165a20469d59d8bb03c55fb6621eb2041a060ae8ea3e9642", size = 3601143, upload-time = "2026-02-11T04:22:04.909Z" }, + { url = "https://files.pythonhosted.org/packages/59/04/dc5c3f297510ba9a6837cbb318b87dd2b8f73eb41a43cc63767f65cb599c/pillow-12.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2815a87ab27848db0321fb78c7f0b2c8649dee134b7f2b80c6a45c6831d75ccd", size = 5266254, upload-time = "2026-02-11T04:22:07.656Z" }, + { url = "https://files.pythonhosted.org/packages/05/30/5db1236b0d6313f03ebf97f5e17cda9ca060f524b2fcc875149a8360b21c/pillow-12.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f7ed2c6543bad5a7d5530eb9e78c53132f93dfa44a28492db88b41cdab885202", size = 4657499, upload-time = "2026-02-11T04:22:09.613Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/008d2ca0eb612e81968e8be0bbae5051efba24d52debf930126d7eaacbba/pillow-12.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:652a2c9ccfb556235b2b501a3a7cf3742148cd22e04b5625c5fe057ea3e3191f", size = 6232137, upload-time = "2026-02-11T04:22:11.434Z" }, + { url = "https://files.pythonhosted.org/packages/70/f1/f14d5b8eeb4b2cd62b9f9f847eb6605f103df89ef619ac68f92f748614ea/pillow-12.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d6e4571eedf43af33d0fc233a382a76e849badbccdf1ac438841308652a08e1f", size = 8042721, upload-time = "2026-02-11T04:22:13.321Z" }, + { url = "https://files.pythonhosted.org/packages/5a/d6/17824509146e4babbdabf04d8171491fa9d776f7061ff6e727522df9bd03/pillow-12.1.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b574c51cf7d5d62e9be37ba446224b59a2da26dc4c1bb2ecbe936a4fb1a7cb7f", size = 6347798, upload-time = "2026-02-11T04:22:15.449Z" }, + { url = "https://files.pythonhosted.org/packages/d1/ee/c85a38a9ab92037a75615aba572c85ea51e605265036e00c5b67dfafbfe2/pillow-12.1.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a37691702ed687799de29a518d63d4682d9016932db66d4e90c345831b02fb4e", size = 7039315, upload-time = "2026-02-11T04:22:17.24Z" }, + { url = "https://files.pythonhosted.org/packages/ec/f3/bc8ccc6e08a148290d7523bde4d9a0d6c981db34631390dc6e6ec34cacf6/pillow-12.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f95c00d5d6700b2b890479664a06e754974848afaae5e21beb4d83c106923fd0", size = 6462360, upload-time = "2026-02-11T04:22:19.111Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ab/69a42656adb1d0665ab051eec58a41f169ad295cf81ad45406963105408f/pillow-12.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:559b38da23606e68681337ad74622c4dbba02254fc9cb4488a305dd5975c7eeb", size = 7165438, upload-time = "2026-02-11T04:22:21.041Z" }, + { url = "https://files.pythonhosted.org/packages/02/46/81f7aa8941873f0f01d4b55cc543b0a3d03ec2ee30d617a0448bf6bd6dec/pillow-12.1.1-cp314-cp314-win32.whl", hash = "sha256:03edcc34d688572014ff223c125a3f77fb08091e4607e7745002fc214070b35f", size = 6431503, upload-time = "2026-02-11T04:22:22.833Z" }, + { url = "https://files.pythonhosted.org/packages/40/72/4c245f7d1044b67affc7f134a09ea619d4895333d35322b775b928180044/pillow-12.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:50480dcd74fa63b8e78235957d302d98d98d82ccbfac4c7e12108ba9ecbdba15", size = 7176748, upload-time = "2026-02-11T04:22:24.64Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ad/8a87bdbe038c5c698736e3348af5c2194ffb872ea52f11894c95f9305435/pillow-12.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:5cb1785d97b0c3d1d1a16bc1d710c4a0049daefc4935f3a8f31f827f4d3d2e7f", size = 2544314, upload-time = "2026-02-11T04:22:26.685Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9d/efd18493f9de13b87ede7c47e69184b9e859e4427225ea962e32e56a49bc/pillow-12.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1f90cff8aa76835cba5769f0b3121a22bd4eb9e6884cfe338216e557a9a548b8", size = 5268612, upload-time = "2026-02-11T04:22:29.884Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f1/4f42eb2b388eb2ffc660dcb7f7b556c1015c53ebd5f7f754965ef997585b/pillow-12.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1f1be78ce9466a7ee64bfda57bdba0f7cc499d9794d518b854816c41bf0aa4e9", size = 4660567, upload-time = "2026-02-11T04:22:31.799Z" }, + { url = "https://files.pythonhosted.org/packages/01/54/df6ef130fa43e4b82e32624a7b821a2be1c5653a5fdad8469687a7db4e00/pillow-12.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:42fc1f4677106188ad9a55562bbade416f8b55456f522430fadab3cef7cd4e60", size = 6269951, upload-time = "2026-02-11T04:22:33.921Z" }, + { url = "https://files.pythonhosted.org/packages/a9/48/618752d06cc44bb4aae8ce0cd4e6426871929ed7b46215638088270d9b34/pillow-12.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:98edb152429ab62a1818039744d8fbb3ccab98a7c29fc3d5fcef158f3f1f68b7", size = 8074769, upload-time = "2026-02-11T04:22:35.877Z" }, + { url = "https://files.pythonhosted.org/packages/c3/bd/f1d71eb39a72fa088d938655afba3e00b38018d052752f435838961127d8/pillow-12.1.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d470ab1178551dd17fdba0fef463359c41aaa613cdcd7ff8373f54be629f9f8f", size = 6381358, upload-time = "2026-02-11T04:22:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/64/ef/c784e20b96674ed36a5af839305f55616f8b4f8aa8eeccf8531a6e312243/pillow-12.1.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6408a7b064595afcab0a49393a413732a35788f2a5092fdc6266952ed67de586", size = 7068558, upload-time = "2026-02-11T04:22:39.597Z" }, + { url = "https://files.pythonhosted.org/packages/73/cb/8059688b74422ae61278202c4e1ad992e8a2e7375227be0a21c6b87ca8d5/pillow-12.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5d8c41325b382c07799a3682c1c258469ea2ff97103c53717b7893862d0c98ce", size = 6493028, upload-time = "2026-02-11T04:22:42.73Z" }, + { url = "https://files.pythonhosted.org/packages/c6/da/e3c008ed7d2dd1f905b15949325934510b9d1931e5df999bb15972756818/pillow-12.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c7697918b5be27424e9ce568193efd13d925c4481dd364e43f5dff72d33e10f8", size = 7191940, upload-time = "2026-02-11T04:22:44.543Z" }, + { url = "https://files.pythonhosted.org/packages/01/4a/9202e8d11714c1fc5951f2e1ef362f2d7fbc595e1f6717971d5dd750e969/pillow-12.1.1-cp314-cp314t-win32.whl", hash = "sha256:d2912fd8114fc5545aa3a4b5576512f64c55a03f3ebcca4c10194d593d43ea36", size = 6438736, upload-time = "2026-02-11T04:22:46.347Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/cbce2327eb9885476b3957b2e82eb12c866a8b16ad77392864ad601022ce/pillow-12.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4ceb838d4bd9dab43e06c363cab2eebf63846d6a4aeaea283bbdfd8f1a8ed58b", size = 7182894, upload-time = "2026-02-11T04:22:48.114Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/de599c95ba0a973b94410477f8bf0b6f0b5e67360eb89bcb1ad365258beb/pillow-12.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:7b03048319bfc6170e93bd60728a1af51d3dd7704935feb228c4d4faab35d334", size = 2546446, upload-time = "2026-02-11T04:22:50.342Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/5d43209aa4cb58e0cc80127956ff1796a68b928e6324bbf06ef4db34367b/pillow-12.1.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:600fd103672b925fe62ed08e0d874ea34d692474df6f4bf7ebe148b30f89f39f", size = 5228606, upload-time = "2026-02-11T04:22:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/5f/d5/3b005b4e4fda6698b371fa6c21b097d4707585d7db99e98d9b0b87ac612a/pillow-12.1.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:665e1b916b043cef294bc54d47bf02d87e13f769bc4bc5fa225a24b3a6c5aca9", size = 4622321, upload-time = "2026-02-11T04:22:53.827Z" }, + { url = "https://files.pythonhosted.org/packages/df/36/ed3ea2d594356fd8037e5a01f6156c74bc8d92dbb0fa60746cc96cabb6e8/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:495c302af3aad1ca67420ddd5c7bd480c8867ad173528767d906428057a11f0e", size = 5247579, upload-time = "2026-02-11T04:22:56.094Z" }, + { url = "https://files.pythonhosted.org/packages/54/9a/9cc3e029683cf6d20ae5085da0dafc63148e3252c2f13328e553aaa13cfb/pillow-12.1.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8fd420ef0c52c88b5a035a0886f367748c72147b2b8f384c9d12656678dfdfa9", size = 6989094, upload-time = "2026-02-11T04:22:58.288Z" }, + { url = "https://files.pythonhosted.org/packages/00/98/fc53ab36da80b88df0967896b6c4b4cd948a0dc5aa40a754266aa3ae48b3/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f975aa7ef9684ce7e2c18a3aa8f8e2106ce1e46b94ab713d156b2898811651d3", size = 5313850, upload-time = "2026-02-11T04:23:00.554Z" }, + { url = "https://files.pythonhosted.org/packages/30/02/00fa585abfd9fe9d73e5f6e554dc36cc2b842898cbfc46d70353dae227f8/pillow-12.1.1-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8089c852a56c2966cf18835db62d9b34fef7ba74c726ad943928d494fa7f4735", size = 5963343, upload-time = "2026-02-11T04:23:02.934Z" }, + { url = "https://files.pythonhosted.org/packages/f2/26/c56ce33ca856e358d27fda9676c055395abddb82c35ac0f593877ed4562e/pillow-12.1.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cb9bb857b2d057c6dfc72ac5f3b44836924ba15721882ef103cecb40d002d80e", size = 7029880, upload-time = "2026-02-11T04:23:04.783Z" }, ] [[package]] @@ -4565,8 +4559,8 @@ name = "powerfx" version = "0.0.34" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cffi", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, - { name = "pythonnet", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "cffi", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, + { name = "pythonnet", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9f/fb/6c4bf87e0c74ca1c563921ce89ca1c5785b7576bca932f7255cdf81082a7/powerfx-0.0.34.tar.gz", hash = "sha256:956992e7afd272657ed16d80f4cad24ec95d9e4a79fb9dfa4a068a09e136af32", size = 3237555, upload-time = "2025-12-22T15:50:59.682Z" } wheels = [ @@ -5215,7 +5209,7 @@ name = "pythonnet" version = "3.0.5" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "clr-loader", marker = "(python_full_version < '3.14' and sys_platform == 'darwin') or (python_full_version < '3.14' and sys_platform == 'linux') or (python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "clr-loader", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9a/d6/1afd75edd932306ae9bd2c2d961d603dc2b52fcec51b04afea464f1f6646/pythonnet-3.0.5.tar.gz", hash = "sha256:48e43ca463941b3608b32b4e236db92d8d40db4c58a75ace902985f76dac21cf", size = 239212, upload-time = "2024-12-13T08:30:44.393Z" } wheels = [ @@ -6471,15 +6465,15 @@ wheels = [ [[package]] name = "typer-slim" -version = "0.21.1" +version = "0.21.2" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "annotated-doc", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, { name = "click", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, - { name = "typing-extensions", marker = "sys_platform == 'darwin' or sys_platform == 'linux' or sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/17/d4/064570dec6358aa9049d4708e4a10407d74c99258f8b2136bb8702303f1a/typer_slim-0.21.1.tar.gz", hash = "sha256:73495dd08c2d0940d611c5a8c04e91c2a0a98600cbd4ee19192255a233b6dbfd", size = 110478, upload-time = "2026-01-06T11:21:11.176Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c8/0a/4aca634faf693e33004796b6cee0ae2e1dba375a800c16ab8d3eff4bb800/typer_slim-0.21.1-py3-none-any.whl", hash = "sha256:6e6c31047f171ac93cc5a973c9e617dbc5ab2bddc4d0a3135dc161b4e2020e0d", size = 47444, upload-time = "2026-01-06T11:21:12.441Z" }, + { url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" }, ] [[package]] @@ -6565,27 +6559,27 @@ wheels = [ [[package]] name = "uv" -version = "0.10.0" +version = "0.10.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/09/36/f7fe4de0ad81234ac43938fe39c6ba84595c6b3a1868d786a4d7ad19e670/uv-0.10.0.tar.gz", hash = "sha256:ad01dd614a4bb8eb732da31ade41447026427397c5ad171cc98bd59579ef57ea", size = 3854103, upload-time = "2026-02-05T20:57:55.248Z" } +sdist = { url = "https://files.pythonhosted.org/packages/0d/9a/fe74aa0127cdc26141364e07abf25e5d69b4bf9788758fad9cfecca637aa/uv-0.10.2.tar.gz", hash = "sha256:b5016f038e191cc9ef00e17be802f44363d1b1cc3ef3454d1d76839a4246c10a", size = 3858864, upload-time = "2026-02-10T19:17:51.609Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/69/33fb64aee6ba138b1aaf957e20778e94a8c23732e41cdf68e6176aa2cf4e/uv-0.10.0-py3-none-linux_armv6l.whl", hash = "sha256:38dc0ccbda6377eb94095688c38e5001b8b40dfce14b9654949c1f0b6aa889df", size = 21984662, upload-time = "2026-02-05T20:57:19.076Z" }, - { url = "https://files.pythonhosted.org/packages/1a/5a/e3ff8a98cfbabc5c2d09bf304d2d9d2d7b2e7d60744241ac5ed762015e5c/uv-0.10.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:a165582c1447691109d49d09dccb065d2a23852ff42bf77824ff169909aa85da", size = 21057249, upload-time = "2026-02-05T20:56:48.921Z" }, - { url = "https://files.pythonhosted.org/packages/ee/77/ec8f24f8d0f19c4fda0718d917bb78b9e6f02a4e1963b401f1c4f4614a54/uv-0.10.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:aefea608971f4f23ac3dac2006afb8eb2b2c1a2514f5fee1fac18e6c45fd70c4", size = 19827174, upload-time = "2026-02-05T20:57:10.581Z" }, - { url = "https://files.pythonhosted.org/packages/c6/7e/09b38b93208906728f591f66185a425be3acdb97c448460137d0e6ecb30a/uv-0.10.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:d4b621bcc5d0139502789dc299bae8bf55356d07b95cb4e57e50e2afcc5f43e1", size = 21629522, upload-time = "2026-02-05T20:57:29.959Z" }, - { url = "https://files.pythonhosted.org/packages/89/f3/48d92c90e869331306979efaa29a44c3e7e8376ae343edc729df0d534dfb/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:b4bea728a6b64826d0091f95f28de06dd2dc786384b3d336a90297f123b4da0e", size = 21614812, upload-time = "2026-02-05T20:56:58.103Z" }, - { url = "https://files.pythonhosted.org/packages/ff/43/d0dedfcd4fe6e36cabdbeeb43425cd788604db9d48425e7b659d0f7ba112/uv-0.10.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc0cc2a4bcf9efbff9a57e2aed21c2d4b5a7ec2cc0096e0c33d7b53da17f6a3b", size = 21577072, upload-time = "2026-02-05T20:57:45.455Z" }, - { url = "https://files.pythonhosted.org/packages/c5/90/b8c9320fd8d86f356e37505a02aa2978ed28f9c63b59f15933e98bce97e5/uv-0.10.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:070ca2f0e8c67ca9a8f70ce403c956b7ed9d51e0c2e9dbbcc4efa5e0a2483f79", size = 22829664, upload-time = "2026-02-05T20:57:22.689Z" }, - { url = "https://files.pythonhosted.org/packages/56/9c/2c36b30b05c74b2af0e663e0e68f1d10b91a02a145e19b6774c121120c0b/uv-0.10.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8070c66149c06f9b39092a06f593a2241345ea2b1d42badc6f884c2cc089a1b1", size = 23705815, upload-time = "2026-02-05T20:57:37.604Z" }, - { url = "https://files.pythonhosted.org/packages/6c/a1/8c7fdb14ab72e26ca872e07306e496a6b8cf42353f9bf6251b015be7f535/uv-0.10.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3db1d5390b3a624de672d7b0f9c9d8197693f3b2d3d9c4d9e34686dcbc34197a", size = 22890313, upload-time = "2026-02-05T20:57:26.35Z" }, - { url = "https://files.pythonhosted.org/packages/f3/f8/5c152350b1a6d0af019801f91a1bdeac854c33deb36275f6c934f0113cb5/uv-0.10.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82b46db718763bf742e986ebbc7a30ca33648957a0dcad34382970b992f5e900", size = 22769440, upload-time = "2026-02-05T20:56:53.859Z" }, - { url = "https://files.pythonhosted.org/packages/87/44/980e5399c6f4943b81754be9b7deb87bd56430e035c507984e17267d6a97/uv-0.10.0-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:eb95d28590edd73b8fdd80c27d699c45c52f8305170c6a90b830caf7f36670a4", size = 21695296, upload-time = "2026-02-05T20:57:06.732Z" }, - { url = "https://files.pythonhosted.org/packages/ae/e7/f44ad40275be2087b3910df4678ed62cf0c82eeb3375c4a35037a79747db/uv-0.10.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:5871eef5046a81df3f1636a3d2b4ccac749c23c7f4d3a4bae5496cb2876a1814", size = 22424291, upload-time = "2026-02-05T20:57:49.067Z" }, - { url = "https://files.pythonhosted.org/packages/c2/81/31c0c0a8673140756e71a1112bf8f0fcbb48a4cf4587a7937f5bd55256b6/uv-0.10.0-py3-none-musllinux_1_1_i686.whl", hash = "sha256:1af0ec125a07edb434dfaa98969f6184c1313dbec2860c3c5ce2d533b257132a", size = 22109479, upload-time = "2026-02-05T20:57:02.258Z" }, - { url = "https://files.pythonhosted.org/packages/d7/d1/2eb51bc233bad3d13ad64a0c280fd4d1ebebf5c2939b3900a46670fa2b91/uv-0.10.0-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:45909b9a734250da05b10101e0a067e01ffa2d94bbb07de4b501e3cee4ae0ff3", size = 22972087, upload-time = "2026-02-05T20:57:52.847Z" }, - { url = "https://files.pythonhosted.org/packages/d2/f7/49987207b87b5c21e1f0e81c52892813e8cdf7e318b6373d6585773ebcdd/uv-0.10.0-py3-none-win32.whl", hash = "sha256:d5498851b1f07aa9c9af75578b2029a11743cb933d741f84dcbb43109a968c29", size = 20896746, upload-time = "2026-02-05T20:57:33.426Z" }, - { url = "https://files.pythonhosted.org/packages/80/b2/1370049596c6ff7fa1fe22fccf86a093982eac81017b8c8aff541d7263b2/uv-0.10.0-py3-none-win_amd64.whl", hash = "sha256:edd469425cd62bcd8c8cc0226c5f9043a94e37ed869da8268c80fdbfd3e5015e", size = 23433041, upload-time = "2026-02-05T20:57:41.41Z" }, - { url = "https://files.pythonhosted.org/packages/e3/76/1034c46244feafec2c274ac52b094f35d47c94cdb11461c24cf4be8a0c0c/uv-0.10.0-py3-none-win_arm64.whl", hash = "sha256:e90c509749b3422eebb54057434b7119892330d133b9690a88f8a6b0f3116be3", size = 21880261, upload-time = "2026-02-05T20:57:14.724Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b5/aea88f66284d220be56ef748ed5e1bd11d819be14656a38631f4b55bfd48/uv-0.10.2-py3-none-linux_armv6l.whl", hash = "sha256:69e35aa3e91a245b015365e5e6ca383ecf72a07280c6d00c17c9173f2d3b68ab", size = 22215714, upload-time = "2026-02-10T19:17:34.281Z" }, + { url = "https://files.pythonhosted.org/packages/7f/72/947ba7737ae6cd50de61d268781b9e7717caa3b07e18238ffd547f9fc728/uv-0.10.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0b7eef95c36fe92e7aac399c0dce555474432cbfeaaa23975ed83a63923f78fd", size = 21276485, upload-time = "2026-02-10T19:18:15.415Z" }, + { url = "https://files.pythonhosted.org/packages/d3/38/5c3462b927a93be4ccaaa25138926a5fb6c9e1b72884efd7af77e451d82e/uv-0.10.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:acc08e420abab21de987151059991e3f04bc7f4044d94ca58b5dd547995b4843", size = 20048620, upload-time = "2026-02-10T19:17:26.481Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/d4509b0f5b7740c1af82202e9c69b700d5848b8bd0faa25229e8edd2c19c/uv-0.10.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:aefbcd749ab2ad48bb533ec028607607f7b03be11c83ea152dbb847226cd6285", size = 21870454, upload-time = "2026-02-10T19:17:21.838Z" }, + { url = "https://files.pythonhosted.org/packages/cd/7e/2bcbafcb424bb885817a7e58e6eec9314c190c55935daaafab1858bb82cd/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:fad554c38d9988409ceddfac69a465e6e5f925a8b689e7606a395c20bb4d1d78", size = 21839508, upload-time = "2026-02-10T19:17:59.211Z" }, + { url = "https://files.pythonhosted.org/packages/60/08/16df2c1f8ad121a595316b82f6e381447e8974265b2239c9135eb874f33b/uv-0.10.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6dd2dc41043e92b3316d7124a7bf48c2affe7117c93079419146f083df71933c", size = 21841283, upload-time = "2026-02-10T19:17:41.419Z" }, + { url = "https://files.pythonhosted.org/packages/76/27/a869fec4c03af5e43db700fabe208d8ee8dbd56e0ff568ba792788d505cd/uv-0.10.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:111c05182c5630ac523764e0ec2e58d7b54eb149dbe517b578993a13c2f71aff", size = 23111967, upload-time = "2026-02-10T19:18:11.764Z" }, + { url = "https://files.pythonhosted.org/packages/2a/4a/fb38515d966acfbd80179e626985aab627898ffd02c70205850d6eb44df1/uv-0.10.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:45c3deaba0343fd27ab5385d6b7cde0765df1a15389ee7978b14a51c32895662", size = 23911019, upload-time = "2026-02-10T19:18:26.947Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5f/51bcbb490ddb1dcb06d767f0bde649ad2826686b9e30efa57f8ab2750a1d/uv-0.10.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bb2cac4f3be60b64a23d9f035019c30a004d378b563c94f60525c9591665a56b", size = 23030217, upload-time = "2026-02-10T19:17:37.789Z" }, + { url = "https://files.pythonhosted.org/packages/46/69/144f6db851d49aa6f25b040dc5c8c684b8f92df9e8d452c7abc619c6ec23/uv-0.10.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:937687df0380d636ceafcb728cf6357f0432588e721892128985417b283c3b54", size = 23036452, upload-time = "2026-02-10T19:18:18.97Z" }, + { url = "https://files.pythonhosted.org/packages/66/29/3c7c4559c9310ed478e3d6c585ee0aad2852dc4d5fb14f4d92a2a12d1728/uv-0.10.2-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:f90bca8703ae66bccfcfb7313b4b697a496c4d3df662f4a1a2696a6320c47598", size = 21941903, upload-time = "2026-02-10T19:17:30.575Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5a/42883b5ef2ef0b1bc5b70a1da12a6854a929ff824aa8eb1a5571fb27a39b/uv-0.10.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:cca026c2e584788e1264879a123bf499dd8f169b9cafac4a2065a416e09d3823", size = 22651571, upload-time = "2026-02-10T19:18:22.74Z" }, + { url = "https://files.pythonhosted.org/packages/e8/b8/e4f1dda1b3b0cc6c8ac06952bfe7bc28893ff016fb87651c8fafc6dfca96/uv-0.10.2-py3-none-musllinux_1_1_i686.whl", hash = "sha256:9f878837938103ee1307ed3ed5d9228118e3932816ab0deb451e7e16dc8ce82a", size = 22321279, upload-time = "2026-02-10T19:17:49.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/4b/baa16d46469e024846fc1a8aa0cfa63f1f89ad0fd3eaa985359a168c3fb0/uv-0.10.2-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:6ec75cfe638b316b329474aa798c3988e5946ead4d9e977fe4dc6fc2ea3e0b8b", size = 23252208, upload-time = "2026-02-10T19:17:54.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/84/6a74e5ec2ee90e4314905e6d1d1708d473e06405e492ec38868b42645388/uv-0.10.2-py3-none-win32.whl", hash = "sha256:f7f3c7e09bf53b81f55730a67dd86299158f470dffb2bd279b6432feb198d231", size = 21118543, upload-time = "2026-02-10T19:18:07.296Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f9/e5cc6cf3a578b87004e857274df97d3cdecd8e19e965869b9b67c094c20c/uv-0.10.2-py3-none-win_amd64.whl", hash = "sha256:7b3685aa1da15acbe080b4cba8684afbb6baf11c9b04d4d4b347cc18b7b9cfa0", size = 23620790, upload-time = "2026-02-10T19:17:45.204Z" }, + { url = "https://files.pythonhosted.org/packages/df/7a/99979dc08ae6a65f4f7a44c5066699016c6eecdc4e695b7512c2efb53378/uv-0.10.2-py3-none-win_arm64.whl", hash = "sha256:abdd5b3c6b871b17bf852a90346eb7af881345706554fd082346b000a9393afd", size = 22035199, upload-time = "2026-02-10T19:18:03.679Z" }, ] [[package]]