Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -256,6 +258,48 @@ private static IResourceBuilder<PythonAppResource> 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<IInteractionService>();
if (interactionService is null || !interactionService.IsAvailable)
{
return;
}

// Get the virtual environment path from the annotation
if (!resource.TryGetLastAnnotation<PythonEnvironmentAnnotation>(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);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@davidfowl This will create a new prompt every time the resource is started even if a prompt is already visible. If there are multiple Python resources in this state, you'll get a separate prompt for each. It seems we really want this coalescing notification capability in the core so all these integrations don't need to keep implementing it?

}
});

resourceBuilder.WithIconName("CodePyRectangle");

resourceBuilder.WithOtlpExporter();
Expand Down
9 changes: 7 additions & 2 deletions src/Aspire.Hosting.Python/VirtualEnvironment.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ namespace Aspire.Hosting.Python;
/// <param name="virtualEnvironmentPath">The path to the directory containing the python app files.</param>
internal sealed class VirtualEnvironment(string virtualEnvironmentPath)
{
/// <summary>
/// Gets the path to the virtual environment directory.
/// </summary>
public string Location { get; } = virtualEnvironmentPath;

/// <summary>
/// Locates an executable in the virtual environment.
/// </summary>
Expand All @@ -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);
}
}
125 changes: 125 additions & 0 deletions tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@

#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;
using Aspire.Hosting.Tests.Utils;
using System.Diagnostics;
using Aspire.TestUtilities;
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Tests;

namespace Aspire.Hosting.Python.Tests;

Expand Down Expand Up @@ -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<PythonEnvironmentAnnotation>(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<IInteractionService>(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<NotificationInteractionOptions>(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<IInteractionService>(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<IInteractionService>(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<PythonEnvironmentAnnotation>(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
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

<ItemGroup>
<Compile Include="$(TestsSharedDir)\TestModuleInitializer.cs" />
<Compile Include="$(TestsSharedDir)\TestInteractionService.cs" />
</ItemGroup>

</Project>
Loading