Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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
68 changes: 64 additions & 4 deletions src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,12 @@ public static IResourceBuilder<PythonAppResource> AddPythonApp(
/// <remarks>
/// <para>
/// This method executes a Python script directly using <c>python script.py</c>.
/// By default, the virtual environment folder is expected to be named <c>.venv</c> and located in the app directory.
/// By default, the virtual environment is resolved using the following priority:
/// <list type="number">
/// <item>If <c>.venv</c> exists in the app directory, use it.</item>
/// <item>If <c>.venv</c> exists in the AppHost directory (and the app is nearby), use it.</item>
Copy link
Member

Choose a reason for hiding this comment

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

What does "and the app is nearby" mean?

Copy link
Member

Choose a reason for hiding this comment

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

Sibling folder or child folder

/// <item>Otherwise, default to <c>.venv</c> in the app directory.</item>
/// </list>
/// Use <see cref="WithVirtualEnvironment{T}(IResourceBuilder{T}, string)"/> to specify a different virtual environment path.
/// Use <c>WithArgs</c> to pass arguments to the script.
/// </para>
Expand Down Expand Up @@ -353,6 +358,14 @@ private static IResourceBuilder<T> AddPythonAppCore<T>(
// python will be replaced with the resolved entrypoint based on the virtualEnvironmentPath
var resource = createResource(name, "python", Path.GetFullPath(appDirectory, builder.AppHostDirectory));

// When using the default virtual environment path, intelligently look for existing virtual environments
Copy link
Member

Choose a reason for hiding this comment

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

"intelligently"?

Copy link
Member

Choose a reason for hiding this comment

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

LOL

// in multiple locations: app directory first, then AppHost directory as fallback
var resolvedVenvPath = virtualEnvironmentPath;
if (virtualEnvironmentPath == DefaultVirtualEnvFolder && !Path.IsPathRooted(virtualEnvironmentPath))
Copy link
Member

@eerhardt eerhardt Nov 3, 2025

Choose a reason for hiding this comment

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

Why are both of these checks necessary?

DefaultVirtualEnvFolder is .venv, so if virtualEnvironmentPath is .venv, it will never be rooted.

Suggested change
if (virtualEnvironmentPath == DefaultVirtualEnvFolder && !Path.IsPathRooted(virtualEnvironmentPath))
if (virtualEnvironmentPath == DefaultVirtualEnvFolder)
``` #Closed

Copy link
Member

Choose a reason for hiding this comment

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

Old deprecated methods let you specify the vdir in the AddPythonApp call.

Copy link
Member

Choose a reason for hiding this comment

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

But if the string virtualEnvironmentPath == ".venv", it will never be rooted.

{
resolvedVenvPath = ResolveDefaultVirtualEnvironmentPath(builder, resource.WorkingDirectory, virtualEnvironmentPath);
}

var resourceBuilder = builder
.AddResource(resource)
// Order matters, we need to bootstrap the entrypoint before setting the entrypoint
Expand All @@ -362,7 +375,7 @@ private static IResourceBuilder<T> AddPythonAppCore<T>(
Entrypoint = entrypoint
})
// This will resolve the correct python executable based on the virtual environment
.WithVirtualEnvironment(virtualEnvironmentPath)
.WithVirtualEnvironment(resolvedVenvPath)
// This will set up the the entrypoint based on the PythonEntrypointAnnotation
.WithEntrypoint(entrypointType, entrypoint);

Expand Down Expand Up @@ -627,6 +640,45 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
}
}

/// <summary>
/// Resolves the default virtual environment path by checking multiple candidate locations.
/// </summary>
/// <param name="builder">The distributed application builder.</param>
/// <param name="appWorkingDirectory">The Python app working directory.</param>
/// <param name="virtualEnvironmentPath">The relative virtual environment path (e.g., ".venv").</param>
/// <returns>The resolved virtual environment path.</returns>
private static string ResolveDefaultVirtualEnvironmentPath(IDistributedApplicationBuilder builder, string appWorkingDirectory, string virtualEnvironmentPath)
{
// Priority 1: Check if the virtual environment exists in the app directory
var appDirVenvPath = Path.GetFullPath(virtualEnvironmentPath, appWorkingDirectory);
if (Directory.Exists(appDirVenvPath))
{
return appDirVenvPath;
}

// Priority 2: Check the AppHost directory if the Python app is a subdirectory or sibling of the AppHost
// This prevents picking up unrelated .venv directories from test fixtures or other sources
var appHostDirVenvPath = Path.GetFullPath(virtualEnvironmentPath, builder.AppHostDirectory);

// Check if the app directory is "nearby" the AppHost directory.
// "Nearby" means the Python app is either:
// - A subdirectory of the AppHost directory (e.g., AppHost/python-app)
// - A sibling of the AppHost directory (e.g., AppHost/../python-app)
// This is determined by checking if the relative path doesn't start with ".." (going up directories)
// and isn't an absolute path (completely unrelated location like /tmp or C:\Temp).
var appDirRelativeToAppHost = Path.GetRelativePath(builder.AppHostDirectory, appWorkingDirectory);
var isAppDirNearAppHost = !appDirRelativeToAppHost.StartsWith("..", StringComparison.Ordinal) &&
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure the logic here aligns with the comments.

If I have:

  • builder.AppHostDirectory = D:\DotNetTest\RelativePath\RelativePath.AppHost
  • appWorkingDirectory = D:\DotNetTest\RelativePath\RelativePath.ApiService

Then Console.WriteLine(Path.GetRelativePath(builder.AppHostDirectory, appWorkingDirectory)); produces:

..\RelativePath.ApiService

!Path.IsPathRooted(appDirRelativeToAppHost);
Copy link

Copilot AI Nov 3, 2025

Choose a reason for hiding this comment

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

The comment states the check prevents "completely unrelated location like /tmp or C:\Temp" but Path.IsPathRooted(appDirRelativeToAppHost) would never be true for a relative path returned by Path.GetRelativePath. The condition Path.IsPathRooted(appDirRelativeToAppHost) is redundant since GetRelativePath always returns a relative path. The comment should be updated to accurately reflect that this check is for defensive purposes or the redundant condition should be removed.

Suggested change
// This is determined by checking if the relative path doesn't start with ".." (going up directories)
// and isn't an absolute path (completely unrelated location like /tmp or C:\Temp).
var appDirRelativeToAppHost = Path.GetRelativePath(builder.AppHostDirectory, appWorkingDirectory);
var isAppDirNearAppHost = !appDirRelativeToAppHost.StartsWith("..", StringComparison.Ordinal) &&
!Path.IsPathRooted(appDirRelativeToAppHost);
// This is determined by checking if the relative path doesn't start with ".." (going up directories).
// The check is defensive and ensures the Python app is not located outside or above the AppHost directory.
var appDirRelativeToAppHost = Path.GetRelativePath(builder.AppHostDirectory, appWorkingDirectory);
var isAppDirNearAppHost = !appDirRelativeToAppHost.StartsWith("..", StringComparison.Ordinal);

Copilot uses AI. Check for mistakes.

if (isAppDirNearAppHost && Directory.Exists(appHostDirVenvPath))
{
return appHostDirVenvPath;
}

// Default: Return app directory path (for cases where the venv will be created later)
return appDirVenvPath;
}

/// <summary>
/// Configures a custom virtual environment path for the Python application.
/// </summary>
Expand All @@ -646,6 +698,11 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
/// Virtual environments allow Python applications to have isolated dependencies separate from
/// the system Python installation. This is the recommended approach for Python applications.
/// </para>
/// <para>
/// When you explicitly specify a virtual environment path using this method, the path is used verbatim.
/// The automatic multi-location lookup (checking both app and AppHost directories) only applies when
/// using the default ".venv" path during initial app creation via AddPythonScript, AddPythonModule, or AddPythonExecutable.
/// </para>
/// </remarks>
/// <example>
/// Configure a Python app to use a custom virtual environment:
Expand All @@ -660,9 +717,12 @@ public static IResourceBuilder<T> WithVirtualEnvironment<T>(
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(virtualEnvironmentPath);

var virtualEnvironment = new VirtualEnvironment(Path.IsPathRooted(virtualEnvironmentPath)
// Use the provided path verbatim - resolve relative paths against the app working directory
var resolvedPath = Path.IsPathRooted(virtualEnvironmentPath)
? virtualEnvironmentPath
: Path.GetFullPath(virtualEnvironmentPath, builder.Resource.WorkingDirectory));
: Path.GetFullPath(virtualEnvironmentPath, builder.Resource.WorkingDirectory);

var virtualEnvironment = new VirtualEnvironment(resolvedPath);
Comment on lines +748 to +753
Copy link
Member

Choose a reason for hiding this comment

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

For my understanding, the logic here hasn't changed at all. It is just refactored into an intermediate variable. Correct?


// Get the entrypoint annotation to determine how to update the command
if (!builder.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var entrypointAnnotation))
Expand Down
196 changes: 196 additions & 0 deletions tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,15 @@ private static void CopyStreamToTestOutput(string label, StreamReader reader, IT
outputHelper.WriteLine($"{label}:\n\n{output}");
}

private static void AssertPythonCommandPath(string expectedVenvPath, string actualCommand)
{
var expectedCommand = OperatingSystem.IsWindows()
? Path.Join(expectedVenvPath, "Scripts", "python.exe")
: Path.Join(expectedVenvPath, "bin", "python");

Assert.Equal(expectedCommand, actualCommand);
}

private const string PythonApp = """"
import logging

Expand Down Expand Up @@ -508,6 +517,193 @@ public async Task WithVirtualEnvironment_CanBeChainedWithOtherExtensions()
Assert.Equal("test_value", environmentVariables["TEST_VAR"]);
}

[Fact]
public void WithVirtualEnvironment_UsesAppDirectoryWhenVenvExistsThere()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempAppDir = new TempDirectory();

// Create .venv in the app directory
var appVenvPath = Path.Combine(tempAppDir.Path, ".venv");
Directory.CreateDirectory(appVenvPath);

var scriptName = "main.py";
var resourceBuilder = builder.AddPythonScript("pythonProject", tempAppDir.Path, scriptName);

var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();

var pythonProjectResource = Assert.Single(executableResources);

// Should use the app directory .venv since it exists there
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path));
var expectedVenvPath = Path.Combine(expectedProjectDirectory, ".venv");

AssertPythonCommandPath(expectedVenvPath, pythonProjectResource.Command);
}

[Fact]
public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempAppDir = new TempDirectory();

// Create app directory as a subdirectory of AppHost (realistic scenario)
var appDirName = "python-app";
var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName);
Directory.CreateDirectory(appDirPath);

// Create .venv in the AppHost directory (not in app directory)
var appHostVenvPath = Path.Combine(builder.AppHostDirectory, ".venv");
Directory.CreateDirectory(appHostVenvPath);

try
{
var scriptName = "main.py";
var resourceBuilder = builder.AddPythonScript("pythonProject", appDirName, scriptName);

var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();

var pythonProjectResource = Assert.Single(executableResources);

// Should use the AppHost directory .venv since it only exists there
AssertPythonCommandPath(appHostVenvPath, pythonProjectResource.Command);
}
finally
{
// Clean up
if (Directory.Exists(appDirPath))
{
Directory.Delete(appDirPath, true);
}
if (Directory.Exists(appHostVenvPath))
{
Directory.Delete(appHostVenvPath, true);
}
}
}

[Fact]
public void WithVirtualEnvironment_PrefersAppDirectoryWhenVenvExistsInBoth()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);

// Create app directory as a subdirectory of AppHost (realistic scenario)
var appDirName = "python-app";
var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName);
Directory.CreateDirectory(appDirPath);

// Create .venv in both directories
var appVenvPath = Path.Combine(appDirPath, ".venv");
Directory.CreateDirectory(appVenvPath);

var appHostVenvPath = Path.Combine(builder.AppHostDirectory, ".venv");
Directory.CreateDirectory(appHostVenvPath);

try
{
var scriptName = "main.py";
var resourceBuilder = builder.AddPythonScript("pythonProject", appDirName, scriptName);

var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();

var pythonProjectResource = Assert.Single(executableResources);

// Should prefer the app directory .venv when it exists in both locations
AssertPythonCommandPath(appVenvPath, pythonProjectResource.Command);
}
finally
{
// Clean up
if (Directory.Exists(appDirPath))
{
Directory.Delete(appDirPath, true);
}
if (Directory.Exists(appHostVenvPath))
{
Directory.Delete(appHostVenvPath, true);
}
}
}

[Fact]
public void WithVirtualEnvironment_DefaultsToAppDirectoryWhenVenvExistsInNeither()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempAppDir = new TempDirectory();

// Don't create .venv in either directory

var scriptName = "main.py";
var resourceBuilder = builder.AddPythonScript("pythonProject", tempAppDir.Path, scriptName);

var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();

var pythonProjectResource = Assert.Single(executableResources);

// Should default to app directory when it doesn't exist in either location
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path));
var expectedVenvPath = Path.Combine(expectedProjectDirectory, ".venv");

AssertPythonCommandPath(expectedVenvPath, pythonProjectResource.Command);
}

[Fact]
public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);

// Create app directory as a subdirectory of AppHost
var appDirName = "python-app";
var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName);
Directory.CreateDirectory(appDirPath);

// Create .venv in the AppHost directory
var appHostVenvPath = Path.Combine(builder.AppHostDirectory, ".venv");
Directory.CreateDirectory(appHostVenvPath);

// Create a custom venv in the app directory
var customVenvPath = Path.Combine(appDirPath, "custom-venv");
Directory.CreateDirectory(customVenvPath);

try
{
var scriptName = "main.py";

// Explicitly specify a custom venv path - should use it verbatim, not fall back to AppHost .venv
var resourceBuilder = builder.AddPythonScript("pythonProject", appDirName, scriptName)
.WithVirtualEnvironment("custom-venv");

var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();

var pythonProjectResource = Assert.Single(executableResources);

// Should use the explicitly specified path, NOT the AppHost .venv
AssertPythonCommandPath(customVenvPath, pythonProjectResource.Command);
}
finally
{
// Clean up
if (Directory.Exists(appDirPath))
{
Directory.Delete(appDirPath, true);
}
if (Directory.Exists(appHostVenvPath))
{
Directory.Delete(appHostVenvPath, true);
}
}
}

[Fact]
public void WithUvEnvironment_CreatesUvEnvironmentResource()
{
Expand Down
Loading