diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 5c9efcaef20..dd6ae3ae156 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,48 @@ 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) => + { + // 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) + { + return; + } + + // 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 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/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 c5b76246e67..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; @@ -1149,5 +1151,128 @@ 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"); + } + + [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 @@ +