diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index f4734988f93..3c50c728c77 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -76,7 +76,12 @@ public static IResourceBuilder AddPythonApp( /// /// /// This method executes a Python script directly using python script.py. - /// By default, the virtual environment folder is expected to be named .venv and located in the app directory. + /// By default, the virtual environment is resolved using the following priority: + /// + /// If .venv exists in the app directory, use it. + /// If .venv exists in the AppHost directory, use it. + /// Otherwise, default to .venv in the app directory. + /// /// Use to specify a different virtual environment path. /// Use WithArgs to pass arguments to the script. /// @@ -350,6 +355,14 @@ private static IResourceBuilder AddPythonAppCore( 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)); @@ -362,7 +375,7 @@ private static IResourceBuilder AddPythonAppCore( 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); @@ -627,6 +640,73 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs) } } + /// + /// Resolves the default virtual environment path by checking multiple candidate locations. + /// + /// The distributed application builder. + /// The Python app directory (relative to AppHost). + /// The relative virtual environment path (e.g., ".venv"). + /// The resolved virtual environment path. + 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 use the app directory + return Path.Combine(appDirectoryFullPath, virtualEnvironmentPath); + } + } + + 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); + } + /// /// Configures a custom virtual environment path for the Python application. /// @@ -646,6 +726,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. /// + /// + /// 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. + /// /// /// /// Configure a Python app to use a custom virtual environment: @@ -660,9 +745,12 @@ public static IResourceBuilder WithVirtualEnvironment( 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); // Get the entrypoint annotation to determine how to update the command if (!builder.Resource.TryGetLastAnnotation(out var entrypointAnnotation)) diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 422bdf45598..ad34c605a79 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -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 @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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() {