Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
101 changes: 97 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, use it.</item>
/// <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 @@ -350,6 +355,14 @@ private static IResourceBuilder<T> AddPythonAppCore<T>(
ArgumentException.ThrowIfNullOrEmpty(entrypoint);
ArgumentNullException.ThrowIfNull(virtualEnvironmentPath);

// When using the default virtual environment path, look for existing virtual environments
// in multiple locations: app directory first, then AppHost directory as fallback
var resolvedVenvPath = virtualEnvironmentPath;
if (virtualEnvironmentPath == DefaultVirtualEnvFolder)
{
resolvedVenvPath = ResolveDefaultVirtualEnvironmentPath(builder, appDirectory, virtualEnvironmentPath);
}

// python will be replaced with the resolved entrypoint based on the virtualEnvironmentPath
var resource = createResource(name, "python", Path.GetFullPath(appDirectory, builder.AppHostDirectory));

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,78 @@ 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="appDirectory">The Python app directory (relative to AppHost).</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 appDirectory, string virtualEnvironmentPath)
{
var appDirectoryFullPath = Path.GetFullPath(appDirectory, builder.AppHostDirectory);

// Walk up from the Python app directory looking for the virtual environment
// Stop at the AppHost's parent directory to avoid picking up unrelated venvs
var appHostParentDirectory = Path.GetDirectoryName(builder.AppHostDirectory);

// Check if the app directory is under the AppHost's parent directory
// If not, only look in the app directory itself
if (appHostParentDirectory != null)
{
var relativePath = Path.GetRelativePath(appHostParentDirectory, appDirectoryFullPath);
var isUnderAppHostParent = !relativePath.StartsWith("..", StringComparison.Ordinal) &&
!Path.IsPathRooted(relativePath);

if (!isUnderAppHostParent)
{
// App is not under AppHost's parent, only check the app directory
var appDirVenvPath = Path.Combine(appDirectoryFullPath, virtualEnvironmentPath);
if (Directory.Exists(appDirVenvPath))
{
return appDirVenvPath;
}
return appDirVenvPath;
}
}

var currentDirectory = appDirectoryFullPath;

while (currentDirectory != null)
{
var venvPath = Path.Combine(currentDirectory, virtualEnvironmentPath);
if (Directory.Exists(venvPath))
{
return venvPath;
}

// Stop if we've reached the AppHost's parent directory
// Use case-insensitive comparison on Windows, case-sensitive on Unix
var reachedBoundary = OperatingSystem.IsWindows()
? string.Equals(currentDirectory, appHostParentDirectory, StringComparison.OrdinalIgnoreCase)
: string.Equals(currentDirectory, appHostParentDirectory, StringComparison.Ordinal);

if (reachedBoundary)
{
break;
}

// Move up to the parent directory
var parentDirectory = Path.GetDirectoryName(currentDirectory);

// Stop if we can't go up anymore or if we've gone beyond the AppHost's parent
if (parentDirectory == null || parentDirectory == currentDirectory)
{
break;
}

currentDirectory = parentDirectory;
}

// Default: Return app directory path (for cases where the venv will be created later)
return Path.Combine(appDirectoryFullPath, virtualEnvironmentPath);
}

/// <summary>
/// Configures a custom virtual environment path for the Python application.
/// </summary>
Expand All @@ -646,6 +731,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 +750,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