From dfe994eb79ada353ebe6a35f6920a0e613af0f2c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Oct 2025 08:04:46 +0000 Subject: [PATCH 1/3] Initial plan From e9bf83a5686ad0fb8d4c97557dda71cd20825fb4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Oct 2025 08:19:52 +0000 Subject: [PATCH 2/3] Add virtual environment validation for Python resources Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../PythonAppResourceBuilderExtensions.cs | 37 +++++++++++++++++++ .../AddPythonAppTests.cs | 23 ++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 5c9efcaef20..b8b5a0e287f 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -4,8 +4,10 @@ using System.ComponentModel; using System.Runtime.CompilerServices; #pragma warning disable ASPIREEXTENSION001 +#pragma warning disable ASPIREINTERACTION001 using Aspire.Hosting.ApplicationModel; using Aspire.Hosting.Python; +using Microsoft.Extensions.DependencyInjection; namespace Aspire.Hosting; @@ -256,6 +258,41 @@ private static IResourceBuilder AddPythonAppCore( // This will set up the the entrypoint based on the PythonEntrypointAnnotation .WithEntrypoint(entrypointType, entrypoint); + // Add virtual environment validation before resource starts + resourceBuilder.OnBeforeResourceStarted(async (resource, evt, ct) => + { + // Get the virtual environment path from the annotation + if (!resource.TryGetLastAnnotation(out var pythonEnv) || + pythonEnv.VirtualEnvironment is null) + { + return; + } + + var venvPath = Path.IsPathRooted(virtualEnvironmentPath) + ? virtualEnvironmentPath + : Path.GetFullPath(virtualEnvironmentPath, resource.WorkingDirectory); + + // Check if the virtual environment directory exists + if (!Directory.Exists(venvPath)) + { + var interactionService = evt.Services.GetService(); + if (interactionService is not null && interactionService.IsAvailable) + { + var title = "Python virtual environment not found"; + var message = $"The Python virtual environment for resource '{resource.Name}' was not found at '{venvPath}'. Please create the virtual environment before running the application."; + var options = new NotificationInteractionOptions + { + Intent = MessageIntent.Error, + LinkText = "Learn more", + LinkUrl = "https://aka.ms/aspire/python" + }; + + // Show notification without blocking (fire and forget) + _ = interactionService.PromptNotificationAsync(title, message, options, ct); + } + } + }); + resourceBuilder.WithIconName("CodePyRectangle"); resourceBuilder.WithOtlpExporter(); diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index c5b76246e67..4f947fed432 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -1149,5 +1149,28 @@ public async Task PythonApp_DoesNotSetPythonUtf8EnvironmentVariable_InPublishMod // PYTHONUTF8 should not be set in Publish mode, even on Windows Assert.False(environmentVariables.ContainsKey("PYTHONUTF8")); } + + [Fact] + public async Task PythonApp_ShowsNotification_WhenVirtualEnvironmentDoesNotExist() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + // Create a script file but don't create the virtual environment + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello, World!')"); + + var pythonApp = builder.AddPythonScript("pythonProject", tempDir.Path, "main.py"); + + using var app = builder.Build(); + + // Verify the resource has the expected annotation + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var annotation)); + Assert.NotNull(annotation.VirtualEnvironment); + + // Verify the virtual environment path doesn't exist + var expectedVenvPath = Path.Combine(tempDir.Path, ".venv"); + Assert.False(Directory.Exists(expectedVenvPath), "Virtual environment should not exist for this test"); + } } From 2d14a36e38af12b1b669adf89cc8e47eee578c76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Oct 2025 08:44:37 +0000 Subject: [PATCH 3/3] Complete virtual environment validation with interaction service - Add Location property to VirtualEnvironment class to expose venv path - Add OnBeforeResourceStarted callback to check if virtual environment exists - Show error notification when venv is missing using IInteractionService - Skip check when IInteractionService is not available - Skip check when using UV environment (WithUvEnvironment) - Add comprehensive tests for notification scenarios - Link to https://aka.ms/aspire/python for documentation Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../PythonAppResourceBuilderExtensions.cs | 39 ++++--- .../VirtualEnvironment.cs | 9 +- .../AddPythonAppTests.cs | 102 ++++++++++++++++++ .../Aspire.Hosting.Python.Tests.csproj | 1 + 4 files changed, 133 insertions(+), 18 deletions(-) diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index b8b5a0e287f..dd6ae3ae156 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -261,6 +261,13 @@ private static IResourceBuilder AddPythonAppCore( // Add virtual environment validation before resource starts resourceBuilder.OnBeforeResourceStarted(async (resource, evt, ct) => { + // Only check if interaction service is available (dashboard is enabled) + var interactionService = evt.Services.GetService(); + if (interactionService is null || !interactionService.IsAvailable) + { + return; + } + // Get the virtual environment path from the annotation if (!resource.TryGetLastAnnotation(out var pythonEnv) || pythonEnv.VirtualEnvironment is null) @@ -268,28 +275,28 @@ private static IResourceBuilder AddPythonAppCore( return; } - var venvPath = Path.IsPathRooted(virtualEnvironmentPath) - ? virtualEnvironmentPath - : Path.GetFullPath(virtualEnvironmentPath, resource.WorkingDirectory); + // Skip check if UV is being used (it creates the virtual environment automatically) + if (pythonEnv.Uv) + { + return; + } + + var venvPath = pythonEnv.VirtualEnvironment.Location; // Check if the virtual environment directory exists if (!Directory.Exists(venvPath)) { - var interactionService = evt.Services.GetService(); - if (interactionService is not null && interactionService.IsAvailable) + var title = "Python virtual environment not found"; + var message = $"The Python virtual environment for resource '{resource.Name}' was not found at '{venvPath}'. Please create the virtual environment before running the application."; + var options = new NotificationInteractionOptions { - var title = "Python virtual environment not found"; - var message = $"The Python virtual environment for resource '{resource.Name}' was not found at '{venvPath}'. Please create the virtual environment before running the application."; - var options = new NotificationInteractionOptions - { - Intent = MessageIntent.Error, - LinkText = "Learn more", - LinkUrl = "https://aka.ms/aspire/python" - }; + Intent = MessageIntent.Error, + LinkText = "Learn more", + LinkUrl = "https://aka.ms/aspire/python" + }; - // Show notification without blocking (fire and forget) - _ = interactionService.PromptNotificationAsync(title, message, options, ct); - } + // Show notification without blocking (fire and forget) + _ = interactionService.PromptNotificationAsync(title, message, options, ct); } }); diff --git a/src/Aspire.Hosting.Python/VirtualEnvironment.cs b/src/Aspire.Hosting.Python/VirtualEnvironment.cs index 08bc7ea4eb2..01c19f8e938 100644 --- a/src/Aspire.Hosting.Python/VirtualEnvironment.cs +++ b/src/Aspire.Hosting.Python/VirtualEnvironment.cs @@ -9,6 +9,11 @@ namespace Aspire.Hosting.Python; /// The path to the directory containing the python app files. internal sealed class VirtualEnvironment(string virtualEnvironmentPath) { + /// + /// Gets the path to the virtual environment directory. + /// + public string Location { get; } = virtualEnvironmentPath; + /// /// Locates an executable in the virtual environment. /// @@ -18,9 +23,9 @@ public string GetExecutable(string name) { if (OperatingSystem.IsWindows()) { - return Path.Join(virtualEnvironmentPath, "Scripts", name + ".exe"); + return Path.Join(Location, "Scripts", name + ".exe"); } - return Path.Join(virtualEnvironmentPath, "bin", name); + return Path.Join(Location, "bin", name); } } diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 4f947fed432..03c8ba4fca1 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -3,6 +3,7 @@ #pragma warning disable CS0612 #pragma warning disable CS0618 // Type or member is obsolete +#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. using Microsoft.Extensions.DependencyInjection; using Aspire.Hosting.Utils; @@ -10,6 +11,7 @@ using System.Diagnostics; using Aspire.TestUtilities; using Aspire.Hosting.ApplicationModel; +using Aspire.Hosting.Tests; namespace Aspire.Hosting.Python.Tests; @@ -1172,5 +1174,105 @@ public async Task PythonApp_ShowsNotification_WhenVirtualEnvironmentDoesNotExist var expectedVenvPath = Path.Combine(tempDir.Path, ".venv"); Assert.False(Directory.Exists(expectedVenvPath), "Virtual environment should not exist for this test"); } + + [Fact] + public async Task PythonApp_SendsNotification_WhenVirtualEnvironmentDoesNotExist() + { + var testInteractionService = new TestInteractionService(); + + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + builder.Services.AddSingleton(testInteractionService); + + using var tempDir = new TempDirectory(); + + // Create a script file but don't create the virtual environment + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello, World!')"); + + var pythonApp = builder.AddPythonScript("pythonProject", tempDir.Path, "main.py"); + + using var app = builder.Build(); + + // Start the app to trigger the BeforeResourceStartedEvent + await app.StartAsync(); + + // Read the notification from the interaction service + var hasNotification = await testInteractionService.Interactions.Reader.WaitToReadAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5)); + Assert.True(hasNotification, "Should have received a notification"); + + var notification = await testInteractionService.Interactions.Reader.ReadAsync(); + + // Verify the notification properties + Assert.Equal("Python virtual environment not found", notification.Title); + Assert.Contains("pythonProject", notification.Message); + Assert.Contains(tempDir.Path, notification.Message); + + // Verify notification options + Assert.NotNull(notification.Options); + var notificationOptions = Assert.IsType(notification.Options); + Assert.Equal(MessageIntent.Error, notificationOptions.Intent); + Assert.Equal("Learn more", notificationOptions.LinkText); + Assert.Equal("https://aka.ms/aspire/python", notificationOptions.LinkUrl); + + await app.StopAsync(); + } + + [Fact] + public async Task PythonApp_DoesNotSendNotification_WhenInteractionServiceNotAvailable() + { + var testInteractionService = new TestInteractionService { IsAvailable = false }; + + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + builder.Services.AddSingleton(testInteractionService); + + using var tempDir = new TempDirectory(); + + // Create a script file but don't create the virtual environment + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello, World!')"); + + var pythonApp = builder.AddPythonScript("pythonProject", tempDir.Path, "main.py"); + + using var app = builder.Build(); + + // Start the app to trigger the BeforeResourceStartedEvent + await app.StartAsync(); + + // Give a moment for any potential notification + await Task.Delay(500); + + // Verify no notification was sent + var hasNotification = testInteractionService.Interactions.Reader.TryRead(out _); + Assert.False(hasNotification, "Should not have received a notification when IsAvailable is false"); + + await app.StopAsync(); + } + + [Fact] + public void PythonApp_DoesNotSendNotification_WhenUsingUvEnvironment() + { + var testInteractionService = new TestInteractionService(); + + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + builder.Services.AddSingleton(testInteractionService); + + using var tempDir = new TempDirectory(); + + // Create a script file + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello, World!')"); + + var pythonApp = builder.AddPythonScript("pythonProject", tempDir.Path, "main.py") + .WithUvEnvironment(); // UV creates the venv automatically + + using var app = builder.Build(); + + // Verify the PythonEnvironmentAnnotation has Uv set to true + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var annotation)); + Assert.True(annotation.Uv, "UV should be enabled"); + + // The actual notification check would happen at runtime, but since UV is enabled, + // the check would be skipped even if the venv doesn't exist + } } diff --git a/tests/Aspire.Hosting.Python.Tests/Aspire.Hosting.Python.Tests.csproj b/tests/Aspire.Hosting.Python.Tests/Aspire.Hosting.Python.Tests.csproj index e3a4853b7a7..12c550d976f 100644 --- a/tests/Aspire.Hosting.Python.Tests/Aspire.Hosting.Python.Tests.csproj +++ b/tests/Aspire.Hosting.Python.Tests/Aspire.Hosting.Python.Tests.csproj @@ -20,6 +20,7 @@ +