Skip to content

Commit f5de144

Browse files
Copilotdavidfowleerhardt
authored
Python hosting: Support .venv lookup by walking up parent directories (#12616)
* Initial plan * Implement .venv lookup in both Python app and AppHost directories Co-authored-by: davidfowl <[email protected]> * Update documentation for WithVirtualEnvironment fallback behavior Co-authored-by: davidfowl <[email protected]> * Refactor: Move .venv lookup logic to AddPythonAppCore, use explicit paths verbatim Co-authored-by: davidfowl <[email protected]> * Refactor tests: Add helper method for Python path assertions and simplify directory naming Co-authored-by: davidfowl <[email protected]> * Add VIRTUAL_ENV configuration support to virtual environment lookup Co-authored-by: davidfowl <[email protected]> * Remove VIRTUAL_ENV configuration check from virtual environment lookup Co-authored-by: davidfowl <[email protected]> * Add clarifying comments for "nearby" check in virtual environment lookup Co-authored-by: davidfowl <[email protected]> * Address PR feedback: simplify logic, use appDirectory, remove redundant checks Co-authored-by: davidfowl <[email protected]> * Walk up parent directories from Python app, stop at AppHost's parent Co-authored-by: davidfowl <[email protected]> * Use platform-aware path comparison for directory boundary check Co-authored-by: eerhardt <[email protected]> * Only walk up directories when app is under AppHost's parent tree Co-authored-by: eerhardt <[email protected]> * Update src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: davidfowl <[email protected]> Co-authored-by: eerhardt <[email protected]> Co-authored-by: Eric Erhardt <[email protected]>
1 parent 80fabe3 commit f5de144

File tree

2 files changed

+288
-4
lines changed

2 files changed

+288
-4
lines changed

src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs

Lines changed: 92 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,12 @@ public static IResourceBuilder<PythonAppResource> AddPythonApp(
7777
/// <remarks>
7878
/// <para>
7979
/// This method executes a Python script directly using <c>python script.py</c>.
80-
/// By default, the virtual environment folder is expected to be named <c>.venv</c> and located in the app directory.
80+
/// By default, the virtual environment is resolved using the following priority:
81+
/// <list type="number">
82+
/// <item>If <c>.venv</c> exists in the app directory, use it.</item>
83+
/// <item>If <c>.venv</c> exists in the AppHost directory, use it.</item>
84+
/// <item>Otherwise, default to <c>.venv</c> in the app directory.</item>
85+
/// </list>
8186
/// Use <see cref="WithVirtualEnvironment{T}(IResourceBuilder{T}, string)"/> to specify a different virtual environment path.
8287
/// Use <c>WithArgs</c> to pass arguments to the script.
8388
/// </para>
@@ -351,6 +356,14 @@ private static IResourceBuilder<T> AddPythonAppCore<T>(
351356
ArgumentException.ThrowIfNullOrEmpty(entrypoint);
352357
ArgumentNullException.ThrowIfNull(virtualEnvironmentPath);
353358

359+
// When using the default virtual environment path, look for existing virtual environments
360+
// in multiple locations: app directory first, then AppHost directory as fallback
361+
var resolvedVenvPath = virtualEnvironmentPath;
362+
if (virtualEnvironmentPath == DefaultVirtualEnvFolder)
363+
{
364+
resolvedVenvPath = ResolveDefaultVirtualEnvironmentPath(builder, appDirectory, virtualEnvironmentPath);
365+
}
366+
354367
// python will be replaced with the resolved entrypoint based on the virtualEnvironmentPath
355368
var resource = createResource(name, "python", Path.GetFullPath(appDirectory, builder.AppHostDirectory));
356369

@@ -363,7 +376,7 @@ private static IResourceBuilder<T> AddPythonAppCore<T>(
363376
Entrypoint = entrypoint
364377
})
365378
// This will resolve the correct python executable based on the virtual environment
366-
.WithVirtualEnvironment(virtualEnvironmentPath)
379+
.WithVirtualEnvironment(resolvedVenvPath)
367380
// This will set up the the entrypoint based on the PythonEntrypointAnnotation
368381
.WithEntrypoint(entrypointType, entrypoint);
369382

@@ -707,6 +720,73 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
707720
}
708721
}
709722

723+
/// <summary>
724+
/// Resolves the default virtual environment path by checking multiple candidate locations.
725+
/// </summary>
726+
/// <param name="builder">The distributed application builder.</param>
727+
/// <param name="appDirectory">The Python app directory (relative to AppHost).</param>
728+
/// <param name="virtualEnvironmentPath">The relative virtual environment path (e.g., ".venv").</param>
729+
/// <returns>The resolved virtual environment path.</returns>
730+
private static string ResolveDefaultVirtualEnvironmentPath(IDistributedApplicationBuilder builder, string appDirectory, string virtualEnvironmentPath)
731+
{
732+
var appDirectoryFullPath = Path.GetFullPath(appDirectory, builder.AppHostDirectory);
733+
734+
// Walk up from the Python app directory looking for the virtual environment
735+
// Stop at the AppHost's parent directory to avoid picking up unrelated venvs
736+
var appHostParentDirectory = Path.GetDirectoryName(builder.AppHostDirectory);
737+
738+
// Check if the app directory is under the AppHost's parent directory
739+
// If not, only look in the app directory itself
740+
if (appHostParentDirectory != null)
741+
{
742+
var relativePath = Path.GetRelativePath(appHostParentDirectory, appDirectoryFullPath);
743+
var isUnderAppHostParent = !relativePath.StartsWith("..", StringComparison.Ordinal) &&
744+
!Path.IsPathRooted(relativePath);
745+
746+
if (!isUnderAppHostParent)
747+
{
748+
// App is not under AppHost's parent, only use the app directory
749+
return Path.Combine(appDirectoryFullPath, virtualEnvironmentPath);
750+
}
751+
}
752+
753+
var currentDirectory = appDirectoryFullPath;
754+
755+
while (currentDirectory != null)
756+
{
757+
var venvPath = Path.Combine(currentDirectory, virtualEnvironmentPath);
758+
if (Directory.Exists(venvPath))
759+
{
760+
return venvPath;
761+
}
762+
763+
// Stop if we've reached the AppHost's parent directory
764+
// Use case-insensitive comparison on Windows, case-sensitive on Unix
765+
var reachedBoundary = OperatingSystem.IsWindows()
766+
? string.Equals(currentDirectory, appHostParentDirectory, StringComparison.OrdinalIgnoreCase)
767+
: string.Equals(currentDirectory, appHostParentDirectory, StringComparison.Ordinal);
768+
769+
if (reachedBoundary)
770+
{
771+
break;
772+
}
773+
774+
// Move up to the parent directory
775+
var parentDirectory = Path.GetDirectoryName(currentDirectory);
776+
777+
// Stop if we can't go up anymore or if we've gone beyond the AppHost's parent
778+
if (parentDirectory == null || parentDirectory == currentDirectory)
779+
{
780+
break;
781+
}
782+
783+
currentDirectory = parentDirectory;
784+
}
785+
786+
// Default: Return app directory path (for cases where the venv will be created later)
787+
return Path.Combine(appDirectoryFullPath, virtualEnvironmentPath);
788+
}
789+
710790
/// <summary>
711791
/// Configures a custom virtual environment path for the Python application.
712792
/// </summary>
@@ -726,6 +806,11 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
726806
/// Virtual environments allow Python applications to have isolated dependencies separate from
727807
/// the system Python installation. This is the recommended approach for Python applications.
728808
/// </para>
809+
/// <para>
810+
/// When you explicitly specify a virtual environment path using this method, the path is used verbatim.
811+
/// The automatic multi-location lookup (checking both app and AppHost directories) only applies when
812+
/// using the default ".venv" path during initial app creation via AddPythonScript, AddPythonModule, or AddPythonExecutable.
813+
/// </para>
729814
/// </remarks>
730815
/// <example>
731816
/// Configure a Python app to use a custom virtual environment:
@@ -740,9 +825,12 @@ public static IResourceBuilder<T> WithVirtualEnvironment<T>(
740825
ArgumentNullException.ThrowIfNull(builder);
741826
ArgumentException.ThrowIfNullOrEmpty(virtualEnvironmentPath);
742827

743-
var virtualEnvironment = new VirtualEnvironment(Path.IsPathRooted(virtualEnvironmentPath)
828+
// Use the provided path verbatim - resolve relative paths against the app working directory
829+
var resolvedPath = Path.IsPathRooted(virtualEnvironmentPath)
744830
? virtualEnvironmentPath
745-
: Path.GetFullPath(virtualEnvironmentPath, builder.Resource.WorkingDirectory));
831+
: Path.GetFullPath(virtualEnvironmentPath, builder.Resource.WorkingDirectory);
832+
833+
var virtualEnvironment = new VirtualEnvironment(resolvedPath);
746834

747835
// Get the entrypoint annotation to determine how to update the command
748836
if (!builder.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var entrypointAnnotation))

tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,15 @@ private static void CopyStreamToTestOutput(string label, StreamReader reader, IT
358358
outputHelper.WriteLine($"{label}:\n\n{output}");
359359
}
360360

361+
private static void AssertPythonCommandPath(string expectedVenvPath, string actualCommand)
362+
{
363+
var expectedCommand = OperatingSystem.IsWindows()
364+
? Path.Join(expectedVenvPath, "Scripts", "python.exe")
365+
: Path.Join(expectedVenvPath, "bin", "python");
366+
367+
Assert.Equal(expectedCommand, actualCommand);
368+
}
369+
361370
private const string PythonApp = """"
362371
import logging
363372
@@ -508,6 +517,193 @@ public async Task WithVirtualEnvironment_CanBeChainedWithOtherExtensions()
508517
Assert.Equal("test_value", environmentVariables["TEST_VAR"]);
509518
}
510519

520+
[Fact]
521+
public void WithVirtualEnvironment_UsesAppDirectoryWhenVenvExistsThere()
522+
{
523+
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
524+
using var tempAppDir = new TempDirectory();
525+
526+
// Create .venv in the app directory
527+
var appVenvPath = Path.Combine(tempAppDir.Path, ".venv");
528+
Directory.CreateDirectory(appVenvPath);
529+
530+
var scriptName = "main.py";
531+
var resourceBuilder = builder.AddPythonScript("pythonProject", tempAppDir.Path, scriptName);
532+
533+
var app = builder.Build();
534+
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
535+
var executableResources = appModel.GetExecutableResources();
536+
537+
var pythonProjectResource = Assert.Single(executableResources);
538+
539+
// Should use the app directory .venv since it exists there
540+
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path));
541+
var expectedVenvPath = Path.Combine(expectedProjectDirectory, ".venv");
542+
543+
AssertPythonCommandPath(expectedVenvPath, pythonProjectResource.Command);
544+
}
545+
546+
[Fact]
547+
public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere()
548+
{
549+
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
550+
using var tempAppDir = new TempDirectory();
551+
552+
// Create app directory as a subdirectory of AppHost (realistic scenario)
553+
var appDirName = "python-app";
554+
var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName);
555+
Directory.CreateDirectory(appDirPath);
556+
557+
// Create .venv in the AppHost directory (not in app directory)
558+
var appHostVenvPath = Path.Combine(builder.AppHostDirectory, ".venv");
559+
Directory.CreateDirectory(appHostVenvPath);
560+
561+
try
562+
{
563+
var scriptName = "main.py";
564+
var resourceBuilder = builder.AddPythonScript("pythonProject", appDirName, scriptName);
565+
566+
var app = builder.Build();
567+
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
568+
var executableResources = appModel.GetExecutableResources();
569+
570+
var pythonProjectResource = Assert.Single(executableResources);
571+
572+
// Should use the AppHost directory .venv since it only exists there
573+
AssertPythonCommandPath(appHostVenvPath, pythonProjectResource.Command);
574+
}
575+
finally
576+
{
577+
// Clean up
578+
if (Directory.Exists(appDirPath))
579+
{
580+
Directory.Delete(appDirPath, true);
581+
}
582+
if (Directory.Exists(appHostVenvPath))
583+
{
584+
Directory.Delete(appHostVenvPath, true);
585+
}
586+
}
587+
}
588+
589+
[Fact]
590+
public void WithVirtualEnvironment_PrefersAppDirectoryWhenVenvExistsInBoth()
591+
{
592+
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
593+
594+
// Create app directory as a subdirectory of AppHost (realistic scenario)
595+
var appDirName = "python-app";
596+
var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName);
597+
Directory.CreateDirectory(appDirPath);
598+
599+
// Create .venv in both directories
600+
var appVenvPath = Path.Combine(appDirPath, ".venv");
601+
Directory.CreateDirectory(appVenvPath);
602+
603+
var appHostVenvPath = Path.Combine(builder.AppHostDirectory, ".venv");
604+
Directory.CreateDirectory(appHostVenvPath);
605+
606+
try
607+
{
608+
var scriptName = "main.py";
609+
var resourceBuilder = builder.AddPythonScript("pythonProject", appDirName, scriptName);
610+
611+
var app = builder.Build();
612+
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
613+
var executableResources = appModel.GetExecutableResources();
614+
615+
var pythonProjectResource = Assert.Single(executableResources);
616+
617+
// Should prefer the app directory .venv when it exists in both locations
618+
AssertPythonCommandPath(appVenvPath, pythonProjectResource.Command);
619+
}
620+
finally
621+
{
622+
// Clean up
623+
if (Directory.Exists(appDirPath))
624+
{
625+
Directory.Delete(appDirPath, true);
626+
}
627+
if (Directory.Exists(appHostVenvPath))
628+
{
629+
Directory.Delete(appHostVenvPath, true);
630+
}
631+
}
632+
}
633+
634+
[Fact]
635+
public void WithVirtualEnvironment_DefaultsToAppDirectoryWhenVenvExistsInNeither()
636+
{
637+
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
638+
using var tempAppDir = new TempDirectory();
639+
640+
// Don't create .venv in either directory
641+
642+
var scriptName = "main.py";
643+
var resourceBuilder = builder.AddPythonScript("pythonProject", tempAppDir.Path, scriptName);
644+
645+
var app = builder.Build();
646+
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
647+
var executableResources = appModel.GetExecutableResources();
648+
649+
var pythonProjectResource = Assert.Single(executableResources);
650+
651+
// Should default to app directory when it doesn't exist in either location
652+
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path));
653+
var expectedVenvPath = Path.Combine(expectedProjectDirectory, ".venv");
654+
655+
AssertPythonCommandPath(expectedVenvPath, pythonProjectResource.Command);
656+
}
657+
658+
[Fact]
659+
public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim()
660+
{
661+
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
662+
663+
// Create app directory as a subdirectory of AppHost
664+
var appDirName = "python-app";
665+
var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName);
666+
Directory.CreateDirectory(appDirPath);
667+
668+
// Create .venv in the AppHost directory
669+
var appHostVenvPath = Path.Combine(builder.AppHostDirectory, ".venv");
670+
Directory.CreateDirectory(appHostVenvPath);
671+
672+
// Create a custom venv in the app directory
673+
var customVenvPath = Path.Combine(appDirPath, "custom-venv");
674+
Directory.CreateDirectory(customVenvPath);
675+
676+
try
677+
{
678+
var scriptName = "main.py";
679+
680+
// Explicitly specify a custom venv path - should use it verbatim, not fall back to AppHost .venv
681+
var resourceBuilder = builder.AddPythonScript("pythonProject", appDirName, scriptName)
682+
.WithVirtualEnvironment("custom-venv");
683+
684+
var app = builder.Build();
685+
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
686+
var executableResources = appModel.GetExecutableResources();
687+
688+
var pythonProjectResource = Assert.Single(executableResources);
689+
690+
// Should use the explicitly specified path, NOT the AppHost .venv
691+
AssertPythonCommandPath(customVenvPath, pythonProjectResource.Command);
692+
}
693+
finally
694+
{
695+
// Clean up
696+
if (Directory.Exists(appDirPath))
697+
{
698+
Directory.Delete(appDirPath, true);
699+
}
700+
if (Directory.Exists(appHostVenvPath))
701+
{
702+
Directory.Delete(appHostVenvPath, true);
703+
}
704+
}
705+
}
706+
511707
[Fact]
512708
public void WithUvEnvironment_CreatesUvEnvironmentResource()
513709
{

0 commit comments

Comments
 (0)