diff --git a/playground/python/Python.AppHost/Program.cs b/playground/python/Python.AppHost/Program.cs index 242e0474b04..3f0b16ee901 100644 --- a/playground/python/Python.AppHost/Program.cs +++ b/playground/python/Python.AppHost/Program.cs @@ -3,13 +3,13 @@ var builder = DistributedApplication.CreateBuilder(args); -builder.AddPythonScript("script-only", "../script_only", "main.py"); -builder.AddPythonScript("instrumented-script", "../instrumented_script", "main.py"); +builder.AddPythonApp("script-only", "../script_only", "main.py"); +builder.AddPythonApp("instrumented-script", "../instrumented_script", "main.py"); builder.AddPythonModule("fastapi-app", "../module_only", "uvicorn") .WithArgs("api:app", "--reload", "--host=0.0.0.0", "--port=8000") .WithHttpEndpoint(targetPort: 8000) - .WithUvEnvironment(); + .WithUv(); // Run the same app on another port using uvicorn directly builder.AddPythonExecutable("fastapi-uvicorn-app", "../module_only", "uvicorn") @@ -27,11 +27,11 @@ c.Args.Add("--port=8002"); }) .WithHttpEndpoint(targetPort: 8002) - .WithUvEnvironment(); + .WithUv(); // Uvicorn app using the AddUvicornApp method builder.AddUvicornApp("uvicorn-app", "../uvicorn_app", "app:app") - .WithUvEnvironment() + .WithUv() .WithExternalHttpEndpoints(); #if !SKIP_DASHBOARD_REFERENCE diff --git a/playground/python/flask_app/README.md b/playground/python/flask_app/README.md index b10eb303d8d..be3a7ed524a 100644 --- a/playground/python/flask_app/README.md +++ b/playground/python/flask_app/README.md @@ -23,7 +23,7 @@ This app is configured to run via the Python.AppHost project using `AddPythonMod var flaskApp = builder.AddPythonModule("flask-app", "../flask_app", "flask") .WithArgs("run", "--host=0.0.0.0", "--port=8000") .WithHttpEndpoint(targetPort: 8000) - .WithUvEnvironment(); + .WithUv(); ``` ## Local Development diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index d2d034c8790..c274af21051 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -26,52 +26,11 @@ public static class PythonAppResourceBuilderExtensions private const string DefaultPythonVersion = "3.13"; /// - /// Adds a python application to the application model. + /// Adds a Python application to the application model. /// /// The to add the resource to. /// The name of the resource. - /// The path to the directory containing the python app files. - /// The path to the script relative to the app directory to run. - /// A reference to the . - /// - /// - /// This method is obsolete. Use one of the more specific methods instead: - /// - /// - /// - To run a Python script file - /// - To run a Python module via python -m - /// - To run an executable from the virtual environment - /// - /// - /// These new methods provide better clarity about how the Python application will be executed. - /// You can also use to change the entrypoint type after creation. - /// - /// - /// - /// Replace with : - /// - /// var builder = DistributedApplication.CreateBuilder(args); - /// - /// builder.AddPythonScript("python-app", "../python-app", "main.py") - /// .WithArgs("arg1", "arg2"); - /// - /// builder.Build().Run(); - /// - /// - [Obsolete("Use AddPythonScript, AddPythonModule, or AddPythonExecutable instead for more explicit control over how the Python application is executed.")] - [OverloadResolutionPriority(1)] - [EditorBrowsable(EditorBrowsableState.Never)] - public static IResourceBuilder AddPythonApp( - this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath) - => AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, DefaultVirtualEnvFolder) - .WithDebugging(); - - /// - /// Adds a Python script to the application model. - /// - /// The to add the resource to. - /// The name of the resource. - /// The path to the directory containing the python script. + /// The path to the directory containing the python application. /// The path to the script relative to the app directory to run. /// A reference to the . /// @@ -83,25 +42,26 @@ public static IResourceBuilder AddPythonApp( /// 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 to specify a different virtual environment path. /// Use WithArgs to pass arguments to the script. /// /// - /// Python scripts automatically have debugging support enabled. + /// Python applications automatically have debugging support enabled. /// /// /// - /// Add a FastAPI Python script to the application model: + /// Add a FastAPI Python application to the application model: /// /// var builder = DistributedApplication.CreateBuilder(args); /// - /// builder.AddPythonScript("fastapi-app", "../api", "main.py") + /// builder.AddPythonApp("fastapi-app", "../api", "main.py") /// .WithArgs("arg1", "arg2"); /// /// builder.Build().Run(); /// /// - public static IResourceBuilder AddPythonScript( + [OverloadResolutionPriority(1)] + public static IResourceBuilder AddPythonApp( this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath) => AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, DefaultVirtualEnvFolder) .WithDebugging(); @@ -118,7 +78,7 @@ public static IResourceBuilder AddPythonScript( /// /// This method runs a Python module using python -m <module>. /// By default, the virtual environment folder is expected to be named .venv and located in the app directory. - /// Use to specify a different virtual environment path. + /// Use to specify a different virtual environment path. /// Use WithArgs to pass arguments to the module. /// /// @@ -153,7 +113,7 @@ public static IResourceBuilder AddPythonModule( /// /// This method runs an executable from the virtual environment's bin directory. /// By default, the virtual environment folder is expected to be named .venv and located in the app directory. - /// Use to specify a different virtual environment path. + /// Use to specify a different virtual environment path. /// Use WithArgs to pass arguments to the executable. /// /// @@ -192,7 +152,7 @@ public static IResourceBuilder AddPythonExecutable( /// This overload is obsolete. Use one of the more specific methods instead: /// /// - /// - To run a Python script file + /// - To run a Python script file /// - To run a Python module via python -m /// - To run an executable from the virtual environment /// @@ -233,7 +193,7 @@ public static IResourceBuilder AddPythonApp( /// This overload is obsolete. Use one of the more specific methods instead: /// /// - /// - To run a Python script file + /// - To run a Python script file /// - To run a Python module via python -m /// - To run an executable from the virtual environment /// @@ -290,7 +250,7 @@ public static IResourceBuilder AddPythonApp( /// var builder = DistributedApplication.CreateBuilder(args); /// /// var api = builder.AddUvicornApp("api", "../fastapi-app", "main:app") - /// .WithUvEnvironment() + /// .WithUv() /// .WithExternalHttpEndpoints(); /// /// builder.Build().Run(); @@ -468,9 +428,10 @@ private static IResourceBuilder AddPythonAppCore( var entrypointType = entrypointAnnotation.Type; var entrypoint = entrypointAnnotation.Entrypoint; - - // Check if using UV - var isUsingUv = pythonEnvironmentAnnotation?.Uv ?? false; + + // Check if using UV by looking at the package manager annotation + var isUsingUv = context.Resource.TryGetLastAnnotation(out var pkgMgr) && + pkgMgr.ExecutableName == "uv"; if (isUsingUv) { @@ -496,10 +457,42 @@ private static IResourceBuilder AddPythonAppCore( } }); + if (builder.ExecutionContext.IsRunMode) + { + // Subscribe to BeforeStartEvent for this specific resource to wire up dependencies dynamically + // This allows methods like WithPip, WithUv, and WithVirtualEnvironment to add/remove resources + // and the dependencies will be established based on which resources actually exist + // Only do this in run mode since the installer and venv creator only run in run mode + var resourceToSetup = resourceBuilder.Resource; + builder.Eventing.Subscribe((evt, ct) => + { + // Wire up wait dependencies for this resource based on which child resources exist + SetupDependencies(builder, resourceToSetup); + return Task.CompletedTask; + }); + + // Automatically add pip as the package manager if pyproject.toml or requirements.txt exists + // Only do this in run mode since the installer resource only runs in run mode + // Note: pip supports both pyproject.toml and requirements.txt + var appDirectoryFullPath = Path.GetFullPath(appDirectory, builder.AppHostDirectory); + + if (File.Exists(Path.Combine(appDirectoryFullPath, "pyproject.toml")) || + File.Exists(Path.Combine(appDirectoryFullPath, "requirements.txt"))) + { + resourceBuilder.WithPip(); + } + else + { + // No package files found, but we should still create venv if it doesn't exist + // and createIfNotExists is true (which is the default) + CreateVenvCreatorIfNeeded(resourceBuilder); + } + } + return resourceBuilder; } - private static void GenerateUvDockerfile(DockerfileBuilderCallbackContext context, PythonAppResource resource, + private static void GenerateUvDockerfile(DockerfileBuilderCallbackContext context, PythonAppResource resource, string pythonVersion, EntrypointType entrypointType, string entrypoint) { // Check if uv.lock exists in the working directory @@ -730,11 +723,11 @@ private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs) 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) @@ -742,16 +735,16 @@ private static string ResolveDefaultVirtualEnvironmentPath(IDistributedApplicati 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); @@ -759,27 +752,27 @@ private static string ResolveDefaultVirtualEnvironmentPath(IDistributedApplicati { 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; } @@ -796,11 +789,19 @@ private static string ResolveDefaultVirtualEnvironmentPath(IDistributedApplicati /// When relative, it is resolved from the working directory of the Python application. /// Common values include ".venv", "venv", or "myenv". /// + /// + /// Whether to automatically create the virtual environment if it doesn't exist. Defaults to true. + /// Set to false to disable automatic venv creation (the venv must already exist). + /// /// A reference to the for method chaining. /// /// /// This method updates the Python executable path to use the specified virtual environment. - /// The virtual environment must already exist and be properly initialized before the application runs. + /// + /// + /// By default ( = true), if the virtual environment doesn't exist, + /// it will be automatically created before running the application (when using pip package manager, not uv). + /// Set to false to disable this behavior and require the venv to already exist. /// /// /// Virtual environments allow Python applications to have isolated dependencies separate from @@ -817,10 +818,14 @@ private static string ResolveDefaultVirtualEnvironmentPath(IDistributedApplicati /// /// var python = builder.AddPythonApp("api", "../python-api", "main.py") /// .WithVirtualEnvironment("myenv"); + /// + /// // Disable automatic venv creation (require venv to exist) + /// var python2 = builder.AddPythonApp("api2", "../python-api2", "main.py") + /// .WithVirtualEnvironment("myenv", createIfNotExists: false); /// /// public static IResourceBuilder WithVirtualEnvironment( - this IResourceBuilder builder, string virtualEnvironmentPath) where T : PythonAppResource + this IResourceBuilder builder, string virtualEnvironmentPath, bool createIfNotExists = true) where T : PythonAppResource { ArgumentNullException.ThrowIfNull(builder); ArgumentException.ThrowIfNullOrEmpty(virtualEnvironmentPath); @@ -850,8 +855,15 @@ public static IResourceBuilder WithVirtualEnvironment( builder.WithPythonEnvironment(env => { env.VirtualEnvironment = virtualEnvironment; + env.CreateVenvIfNotExists = createIfNotExists; }); + // If createIfNotExists is false, remove venv creator + if (!createIfNotExists) + { + RemoveVenvCreator(builder); + } + return builder; } @@ -1063,11 +1075,94 @@ public static IResourceBuilder WithEntrypoint( return builder; } + /// + /// Configures the Python resource to use pip as the package manager and optionally installs packages before the application starts. + /// + /// The type of the Python application resource, must derive from . + /// The resource builder. + /// When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource. + /// The command-line arguments passed to pip install command. + /// A reference to the for method chaining. + /// + /// + /// This method creates a child resource that runs pip install in the working directory of the Python application. + /// The Python application will wait for this resource to complete successfully before starting. + /// + /// + /// Pip will automatically detect and use either pyproject.toml or requirements.txt based on which file exists in the application directory. + /// If pyproject.toml exists, pip will use it. Otherwise, if requirements.txt exists, pip will use that. + /// Calling this method will replace any previously configured package manager (such as uv). + /// + /// + /// + /// Add a Python app with automatic pip package installation: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var python = builder.AddPythonScript("api", "../python-api", "main.py") + /// .WithPip() // Automatically installs from pyproject.toml or requirements.txt + /// .WithHttpEndpoint(port: 5000); + /// + /// builder.Build().Run(); + /// + /// + /// Thrown when is null. + public static IResourceBuilder WithPip(this IResourceBuilder builder, bool install = true, string[]? installArgs = null) + where T : PythonAppResource + { + ArgumentNullException.ThrowIfNull(builder); + + // Ensure virtual environment exists - create default .venv if not configured + if (!builder.Resource.TryGetLastAnnotation(out var pythonEnv) || + pythonEnv.VirtualEnvironment is null) + { + // Create default virtual environment if none exists + builder.WithVirtualEnvironment(".venv"); + pythonEnv = builder.Resource.Annotations.OfType().Last(); + } + + var virtualEnvironment = pythonEnv.VirtualEnvironment!; + + // Determine install command based on which file exists + // Pip supports both pyproject.toml and requirements.txt + var workingDirectory = builder.Resource.WorkingDirectory; + string[] baseInstallArgs; + + if (File.Exists(Path.Combine(workingDirectory, "pyproject.toml"))) + { + // Use pip install with pyproject.toml (pip will read from pyproject.toml automatically) + baseInstallArgs = ["install", "."]; + } + else if (File.Exists(Path.Combine(workingDirectory, "requirements.txt"))) + { + // Use pip install with requirements.txt + baseInstallArgs = ["install", "-r", "requirements.txt"]; + } + else + { + // Default to requirements.txt even if it doesn't exist (will fail at runtime if no file is present) + baseInstallArgs = ["install", "-r", "requirements.txt"]; + } + + builder + .WithAnnotation(new PythonPackageManagerAnnotation(virtualEnvironment.GetExecutable("pip")), ResourceAnnotationMutationBehavior.Replace) + .WithAnnotation(new PythonInstallCommandAnnotation([.. baseInstallArgs, .. installArgs ?? []]), ResourceAnnotationMutationBehavior.Replace); + + AddInstaller(builder, install); + + // Create venv creator if needed (will check if venv exists) + CreateVenvCreatorIfNeeded(builder); + + return builder; + } + /// /// Adds a UV environment setup task to ensure the virtual environment exists before running the Python application. /// /// The type of the Python application resource, must derive from . /// The resource builder. + /// When true (default), automatically runs uv sync before the application starts. When false, only sets the package manager annotation without creating an installer resource. + /// Optional custom arguments to pass to the uv command. If not provided, defaults to ["sync"]. /// A reference to the for method chaining. /// /// @@ -1077,11 +1172,10 @@ public static IResourceBuilder WithEntrypoint( /// /// UV (https://github.com/astral-sh/uv) is a modern Python package manager written in Rust that can manage virtual environments /// and dependencies with significantly faster performance than traditional tools. The uv sync command ensures that the virtual - /// environment exists and all dependencies specified in pyproject.toml are installed and synchronized. + /// environment exists, Python is installed if needed, and all dependencies specified in pyproject.toml are installed and synchronized. /// /// - /// This method is idempotent - calling it multiple times on the same resource will not create duplicate UV environment resources. - /// If a UV environment resource already exists for the Python application, it will be reused. + /// Calling this method will replace any previously configured package manager (such as pip). /// /// /// @@ -1089,55 +1183,290 @@ public static IResourceBuilder WithEntrypoint( /// /// var builder = DistributedApplication.CreateBuilder(args); /// - /// var python = builder.AddPythonApp("api", "../python-api", "main.py") - /// .WithUvEnvironment() // Automatically runs 'uv sync' before starting the app + /// var python = builder.AddPythonScript("api", "../python-api", "main.py") + /// .WithUv() // Automatically runs 'uv sync' before starting the app + /// .WithHttpEndpoint(port: 5000); + /// + /// builder.Build().Run(); + /// + /// + /// + /// Add a Python app with custom UV arguments: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var python = builder.AddPythonScript("api", "../python-api", "main.py") + /// .WithUv(args: ["sync", "--python", "3.12", "--no-dev"]) // Custom uv sync args /// .WithHttpEndpoint(port: 5000); /// /// builder.Build().Run(); /// /// /// Thrown when is null. - /// - /// Thrown when a resource with the UV environment name already exists but is not a . - /// - public static IResourceBuilder WithUvEnvironment(this IResourceBuilder builder) + public static IResourceBuilder WithUv(this IResourceBuilder builder, bool install = true, string[]? args = null) where T : PythonAppResource { ArgumentNullException.ThrowIfNull(builder); - var uvEnvironmentName = $"{builder.Resource.Name}-uv-environment"; + // Default args: sync only (uv will auto-detect Python and dependencies from pyproject.toml) + args ??= ["sync"]; - // Check if the UV environment resource already exists - var existingResource = builder.ApplicationBuilder.Resources - .FirstOrDefault(r => string.Equals(r.Name, uvEnvironmentName, StringComparison.OrdinalIgnoreCase)); + builder + .WithAnnotation(new PythonPackageManagerAnnotation("uv"), ResourceAnnotationMutationBehavior.Replace) + .WithAnnotation(new PythonInstallCommandAnnotation(args), ResourceAnnotationMutationBehavior.Replace); - IResourceBuilder uvBuilder; + AddInstaller(builder, install); - if (existingResource is not null) + // UV handles venv creation, so remove any existing venv creator + RemoveVenvCreator(builder); + + return builder; + } + + private static bool IsPythonCommandAvailable(string command) + { + var pathVariable = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(pathVariable)) { - // Resource already exists, return a builder for it - if (existingResource is not PythonUvEnvironmentResource uvEnvironmentResource) + return false; + } + + if (OperatingSystem.IsWindows()) + { + // On Windows, try both .exe and .cmd extensions + foreach (var ext in new[] { ".exe", ".cmd" }) { - throw new DistributedApplicationException($"Cannot add UV environment resource with name '{uvEnvironmentName}' because a resource of type '{existingResource.GetType()}' with that name already exists."); + var commandWithExt = command + ext; + foreach (var directory in pathVariable.Split(Path.PathSeparator)) + { + var fullPath = Path.Combine(directory, commandWithExt); + if (File.Exists(fullPath)) + { + return true; + } + } } - - uvBuilder = builder.ApplicationBuilder.CreateResourceBuilder(uvEnvironmentResource); } else { - // Resource doesn't exist, create it - var uvEnvironmentResource = new PythonUvEnvironmentResource(uvEnvironmentName, builder.Resource); + // On Unix-like systems, no extension needed + foreach (var directory in pathVariable.Split(Path.PathSeparator)) + { + var fullPath = Path.Combine(directory, command); + if (File.Exists(fullPath)) + { + return true; + } + } + } + + return false; + } + + private static void AddInstaller(IResourceBuilder builder, bool install) where T : PythonAppResource + { + // Only install packages if in run mode + if (builder.ApplicationBuilder.ExecutionContext.IsRunMode) + { + // Check if the installer resource already exists + var installerName = $"{builder.Resource.Name}-installer"; + builder.ApplicationBuilder.TryCreateResourceBuilder(installerName, out var existingResource); - uvBuilder = builder.ApplicationBuilder.AddResource(uvEnvironmentResource) - .WithArgs("sync") - .WithParentRelationship(builder) + if (!install) + { + if (existingResource != null) + { + // Remove existing installer resource if install is false + builder.ApplicationBuilder.Resources.Remove(existingResource.Resource); + builder.Resource.Annotations.OfType() + .ToList() + .ForEach(a => builder.Resource.Annotations.Remove(a)); + } + return; + } + + if (existingResource is not null) + { + // Installer already exists, it will be reconfigured via BeforeStartEvent + return; + } + + var installer = new PythonInstallerResource(installerName, builder.Resource); + var installerBuilder = builder.ApplicationBuilder.AddResource(installer) + .WithParentRelationship(builder.Resource) .ExcludeFromManifest(); - builder.WaitForCompletion(uvBuilder) - .WithPythonEnvironment(env => env.Uv = true); + builder.ApplicationBuilder.Eventing.Subscribe((_, _) => + { + // Set the installer's working directory to match the resource's working directory + // and set the install command and args based on the resource's annotations + if (!builder.Resource.TryGetLastAnnotation(out var packageManager) || + !builder.Resource.TryGetLastAnnotation(out var installCommand)) + { + // No package manager configured - don't fail, just don't run the installer + // This allows venv to be created without requiring a package manager + return Task.CompletedTask; + } + + installerBuilder + .WithCommand(packageManager.ExecutableName) + .WithWorkingDirectory(builder.Resource.WorkingDirectory) + .WithArgs(installCommand.Args); + + return Task.CompletedTask; + }); + + builder.WithAnnotation(new PythonPackageInstallerAnnotation(installer)); } + } - return builder; + private static void CreateVenvCreatorIfNeeded(IResourceBuilder builder) where T : PythonAppResource + { + // Check if we should create a venv + if (!ShouldCreateVenv(builder)) + { + return; + } + + if (!builder.Resource.TryGetLastAnnotation(out var pythonEnv) || + pythonEnv.VirtualEnvironment == null) + { + return; + } + + var venvPath = Path.IsPathRooted(pythonEnv.VirtualEnvironment.VirtualEnvironmentPath) + ? pythonEnv.VirtualEnvironment.VirtualEnvironmentPath + : Path.GetFullPath(pythonEnv.VirtualEnvironment.VirtualEnvironmentPath, builder.Resource.WorkingDirectory); + + // Create venv creator as a child resource + var venvCreatorName = $"{builder.Resource.Name}-venv-creator"; + + // Use TryCreateResourceBuilder to check if it already exists + if (builder.ApplicationBuilder.TryCreateResourceBuilder(venvCreatorName, out _)) + { + // Venv creator already exists, no need to create again + return; + } + + // Create new venv creator resource + var venvCreator = new PythonVenvCreatorResource(venvCreatorName, builder.Resource, venvPath); + + // Determine which Python command to use + string pythonCommand; + if (OperatingSystem.IsWindows()) + { + // On Windows, try py launcher first, then python + pythonCommand = IsPythonCommandAvailable("py") ? "py" : "python"; + } + else + { + // On Unix-like systems, try python3 first (more explicit), then python + pythonCommand = IsPythonCommandAvailable("python3") ? "python3" : "python"; + } + + builder.ApplicationBuilder.AddResource(venvCreator) + .WithCommand(pythonCommand) + .WithArgs(["-m", "venv", venvPath]) + .WithWorkingDirectory(builder.Resource.WorkingDirectory) + .WithParentRelationship(builder.Resource) + .ExcludeFromManifest(); + + // Wait relationships will be set up dynamically in SetupDependencies + } + + private static void RemoveVenvCreator(IResourceBuilder builder) where T : PythonAppResource + { + var venvCreatorName = $"{builder.Resource.Name}-venv-creator"; + + // Use TryCreateResourceBuilder to check if venv creator exists + if (builder.ApplicationBuilder.TryCreateResourceBuilder(venvCreatorName, out var venvCreatorBuilder)) + { + builder.ApplicationBuilder.Resources.Remove(venvCreatorBuilder.Resource); + // Wait relationships are managed dynamically in SetupDependencies, so no need to clean them up here + } + } + + private static void SetupDependencies(IDistributedApplicationBuilder builder, PythonAppResource resource) + { + // This method is called in BeforeStartEvent to dynamically set up dependencies + // based on which child resources actually exist after all method calls have been made + + var venvCreatorName = $"{resource.Name}-venv-creator"; + var installerName = $"{resource.Name}-installer"; + + // Try to get the venv creator and installer resources + builder.TryCreateResourceBuilder(venvCreatorName, out var venvCreatorBuilder); + builder.TryCreateResourceBuilder(installerName, out var installerBuilder); + + // Get the Python app resource builder + builder.TryCreateResourceBuilder(resource.Name, out var appBuilder); + + if (appBuilder == null) + { + return; // Resource doesn't exist, nothing to set up + } + + // Set up wait dependencies based on what exists: + // 1. If both venv creator and installer exist: installer waits for venv creator, app waits for installer + // 2. If only installer exists: app waits for installer + // 3. If only venv creator exists: app waits for venv creator (no installer needed) + // 4. If neither exists: app runs directly (no waits needed) + + if (venvCreatorBuilder != null && installerBuilder != null) + { + // Both exist: installer waits for venv, app waits for installer + installerBuilder.WaitForCompletion(venvCreatorBuilder); + appBuilder.WaitForCompletion(installerBuilder); + } + else if (installerBuilder != null) + { + // Only installer exists: app waits for installer + appBuilder.WaitForCompletion(installerBuilder); + } + else if (venvCreatorBuilder != null) + { + // Only venv creator exists: app waits for venv creator + appBuilder.WaitForCompletion(venvCreatorBuilder); + } + // If neither exists, no wait relationships needed + } + + private static bool ShouldCreateVenv(IResourceBuilder builder) where T : PythonAppResource + { + // Check if we're using uv (which handles venv creation itself) + var isUsingUv = builder.Resource.TryGetLastAnnotation(out var pkgMgr) && + pkgMgr.ExecutableName == "uv"; + + if (isUsingUv) + { + // UV handles venv creation, we don't need to create it + return false; + } + + // Get the virtual environment path + if (!builder.Resource.TryGetLastAnnotation(out var pythonEnv) || + pythonEnv.VirtualEnvironment == null) + { + return false; + } + + // Check if automatic venv creation is disabled + if (!pythonEnv.CreateVenvIfNotExists) + { + return false; + } + + var venvPath = Path.IsPathRooted(pythonEnv.VirtualEnvironment.VirtualEnvironmentPath) + ? pythonEnv.VirtualEnvironment.VirtualEnvironmentPath + : Path.GetFullPath(pythonEnv.VirtualEnvironment.VirtualEnvironmentPath, builder.Resource.WorkingDirectory); + + // Check if venv directory exists (simple check, don't verify validity) + if (Directory.Exists(venvPath)) + { + return false; + } + + return true; } internal static IResourceBuilder WithPythonEnvironment(this IResourceBuilder builder, Action configure) diff --git a/src/Aspire.Hosting.Python/PythonEnvironmentAnnotation.cs b/src/Aspire.Hosting.Python/PythonEnvironmentAnnotation.cs index 682fbb356dd..b37cc07a4cc 100644 --- a/src/Aspire.Hosting.Python/PythonEnvironmentAnnotation.cs +++ b/src/Aspire.Hosting.Python/PythonEnvironmentAnnotation.cs @@ -5,12 +5,16 @@ namespace Aspire.Hosting.Python; -// Marker annotation to indicate a resource is for setting up a UV environment for a Python app. +// Marker annotation to indicate a resource is for setting up a Python environment. internal class PythonEnvironmentAnnotation : IResourceAnnotation { public string? Version { get; set; } - public bool Uv { get; set; } - public VirtualEnvironment? VirtualEnvironment { get; set; } + + /// + /// Gets or sets whether to automatically create the virtual environment if it doesn't exist. + /// Defaults to true. + /// + public bool CreateVenvIfNotExists { get; set; } = true; } diff --git a/src/Aspire.Hosting.Python/PythonInstallCommandAnnotation.cs b/src/Aspire.Hosting.Python/PythonInstallCommandAnnotation.cs new file mode 100644 index 00000000000..ca5975fe56e --- /dev/null +++ b/src/Aspire.Hosting.Python/PythonInstallCommandAnnotation.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Python; + +/// +/// Represents the annotation for the Python package manager's install command. +/// +/// +/// The command line arguments for the Python package manager's install command. +/// This includes the command itself (i.e. "install", "sync"). +/// +internal sealed class PythonInstallCommandAnnotation(string[] args) : IResourceAnnotation +{ + /// + /// Gets the command-line arguments supplied to the Python package manager. + /// + public string[] Args { get; } = args; +} diff --git a/src/Aspire.Hosting.Python/PythonUvEnvironmentResource.cs b/src/Aspire.Hosting.Python/PythonInstallerResource.cs similarity index 60% rename from src/Aspire.Hosting.Python/PythonUvEnvironmentResource.cs rename to src/Aspire.Hosting.Python/PythonInstallerResource.cs index 246c03208b5..d2e7ed9d79f 100644 --- a/src/Aspire.Hosting.Python/PythonUvEnvironmentResource.cs +++ b/src/Aspire.Hosting.Python/PythonInstallerResource.cs @@ -6,11 +6,11 @@ namespace Aspire.Hosting.Python; /// -/// A resource that represents a UV environment setup task for a Python application. +/// A resource that represents a Python package installer task for a Python application. /// /// The name of the resource. /// The parent Python application resource. -internal sealed class PythonUvEnvironmentResource(string name, PythonAppResource parent) - : ExecutableResource(name, "uv", parent.WorkingDirectory) +internal sealed class PythonInstallerResource(string name, PythonAppResource parent) + : ExecutableResource(name, "python", parent.WorkingDirectory) { } diff --git a/src/Aspire.Hosting.Python/PythonPackageInstallerAnnotation.cs b/src/Aspire.Hosting.Python/PythonPackageInstallerAnnotation.cs new file mode 100644 index 00000000000..fa44c0d76da --- /dev/null +++ b/src/Aspire.Hosting.Python/PythonPackageInstallerAnnotation.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Python; + +/// +/// Represents an annotation for a Python installer resource. +/// +internal sealed class PythonPackageInstallerAnnotation(ExecutableResource installerResource) : IResourceAnnotation +{ + /// + /// The instance of the Installer resource used. + /// + public ExecutableResource Resource { get; } = installerResource; +} diff --git a/src/Aspire.Hosting.Python/PythonPackageManagerAnnotation.cs b/src/Aspire.Hosting.Python/PythonPackageManagerAnnotation.cs new file mode 100644 index 00000000000..7fe3e51ca39 --- /dev/null +++ b/src/Aspire.Hosting.Python/PythonPackageManagerAnnotation.cs @@ -0,0 +1,18 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Python; + +/// +/// Represents the annotation for the Python package manager used in a resource. +/// +/// The name of the executable used to run the package manager. +internal sealed class PythonPackageManagerAnnotation(string executableName) : IResourceAnnotation +{ + /// + /// Gets the executable used to run the Python package manager. + /// + public string ExecutableName { get; } = executableName; +} diff --git a/src/Aspire.Hosting.Python/PythonVenvCreatorResource.cs b/src/Aspire.Hosting.Python/PythonVenvCreatorResource.cs new file mode 100644 index 00000000000..f95c6411292 --- /dev/null +++ b/src/Aspire.Hosting.Python/PythonVenvCreatorResource.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Aspire.Hosting.ApplicationModel; + +namespace Aspire.Hosting.Python; + +/// +/// A resource that represents a Python virtual environment creator task for a Python application. +/// +/// The name of the resource. +/// The parent Python application resource. +/// The path where the virtual environment should be created. +internal sealed class PythonVenvCreatorResource(string name, PythonAppResource parent, string venvPath) + : ExecutableResource(name, "python", parent.WorkingDirectory) +{ + /// + /// Gets the path where the virtual environment will be created. + /// + public string VenvPath => venvPath; +} diff --git a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs index fb44642a51e..663ef2a5745 100644 --- a/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs +++ b/src/Aspire.ProjectTemplates/templates/aspire-py-starter/13.0/apphost.cs @@ -12,7 +12,7 @@ #endif var app = builder.AddUvicornApp("app", "./app", "main:app") - .WithUvEnvironment() + .WithUv() .WithExternalHttpEndpoints() #if UseRedisCache .WithReference(cache) diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 07fc9f142ee..49148de8f16 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -13,6 +13,7 @@ using Aspire.Hosting.ApplicationModel; using System.Text.Json; using Aspire.Hosting.Dcp.Model; +using Aspire.Hosting.Eventing; namespace Aspire.Hosting.Python.Tests; @@ -103,7 +104,7 @@ public async Task PythonResourceFinishesSuccessfully() var (projectDirectory, _, scriptName) = CreateTempPythonProject(outputHelper); using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); - builder.AddPythonScript("pyproj", projectDirectory, scriptName); + builder.AddPythonApp("pyproj", projectDirectory, scriptName); using var app = builder.Build(); @@ -128,7 +129,7 @@ public async Task PythonResourceSupportsWithReference() var externalResource = builder.AddConnectionString("connectionString"); builder.Configuration["ConnectionStrings:connectionString"] = "test"; - var pyproj = builder.AddPythonScript("pyproj", projectDirectory, scriptName) + var pyproj = builder.AddPythonApp("pyproj", projectDirectory, scriptName) .WithReference(externalResource); using var app = builder.Build(); @@ -154,7 +155,8 @@ public async Task AddPythonApp_SetsResourcePropertiesCorrectly() var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + // Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists) + var pythonProjectResource = executableResources.OfType().Single(); Assert.Equal("pythonProject", pythonProjectResource.Name); Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory); @@ -192,7 +194,8 @@ public async Task AddPythonApp_ObsoleteMethod_StillWorks() var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + // Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists) + var pythonProjectResource = executableResources.OfType().Single(); Assert.Equal("pythonProject", pythonProjectResource.Name); Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory); @@ -221,20 +224,21 @@ public async Task AddPythonApp_ObsoleteMethod_StillWorks() [Fact] [RequiresTools(["python"])] - public async Task AddPythonScriptWithScriptArgs_IncludesTheArguments() + public async Task AddPythonAppWithScriptArgs_IncludesTheArguments() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); var (projectDirectory, pythonExecutable, scriptName) = CreateTempPythonProject(outputHelper); - builder.AddPythonScript("pythonProject", projectDirectory, scriptName) + builder.AddPythonApp("pythonProject", projectDirectory, scriptName) .WithArgs("test"); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + // Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists) + var pythonProjectResource = executableResources.OfType().Single(); Assert.Equal("pythonProject", pythonProjectResource.Name); Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory); @@ -383,14 +387,14 @@ import logging """"; [Fact] - public void AddPythonScript_DoesNotThrowOnMissingVirtualEnvironment() + public void AddPythonApp_DoesNotThrowOnMissingVirtualEnvironment() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); // Should not throw - validation is deferred until runtime var exception = Record.Exception(() => - builder.AddPythonScript("pythonProject", tempDir.Path, "main.py")); + builder.AddPythonApp("pythonProject", tempDir.Path, "main.py")); Assert.Null(exception); } @@ -403,14 +407,14 @@ public async Task WithVirtualEnvironment_UpdatesCommandToUseNewVirtualEnvironmen var scriptName = "main.py"; - builder.AddPythonScript("pythonProject", tempDir.Path, scriptName) + builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) .WithVirtualEnvironment("custom-venv"); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path)); @@ -436,14 +440,14 @@ public async Task WithVirtualEnvironment_SupportsAbsolutePath() var scriptName = "main.py"; - builder.AddPythonScript("pythonProject", tempDir.Path, scriptName) + builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) .WithVirtualEnvironment(tempVenvDir.Path); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); if (OperatingSystem.IsWindows()) { @@ -476,7 +480,7 @@ public void WithVirtualEnvironment_ThrowsOnNullOrEmptyPath() using var tempDir = new TempDirectory(); var scriptName = "main.py"; - var resourceBuilder = builder.AddPythonScript("pythonProject", tempDir.Path, scriptName); + var resourceBuilder = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName); var nullException = Assert.Throws(() => resourceBuilder.WithVirtualEnvironment(null!)); @@ -495,7 +499,7 @@ public async Task WithVirtualEnvironment_CanBeChainedWithOtherExtensions() var scriptName = "main.py"; - var resourceBuilder = builder.AddPythonScript("pythonProject", tempDir.Path, scriptName) + var resourceBuilder = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) .WithVirtualEnvironment(".venv") .WithArgs("arg1", "arg2") .WithEnvironment("TEST_VAR", "test_value"); @@ -504,7 +508,7 @@ public async Task WithVirtualEnvironment_CanBeChainedWithOtherExtensions() var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance); Assert.Equal(3, commandArguments.Count); @@ -528,13 +532,13 @@ public void WithVirtualEnvironment_UsesAppDirectoryWhenVenvExistsThere() Directory.CreateDirectory(appVenvPath); var scriptName = "main.py"; - var resourceBuilder = builder.AddPythonScript("pythonProject", tempAppDir.Path, scriptName); + var resourceBuilder = builder.AddPythonApp("pythonProject", tempAppDir.Path, scriptName); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); // Should use the app directory .venv since it exists there var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path)); @@ -561,13 +565,13 @@ public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere() try { var scriptName = "main.py"; - var resourceBuilder = builder.AddPythonScript("pythonProject", appDirName, scriptName); + var resourceBuilder = builder.AddPythonApp("pythonProject", appDirName, scriptName); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); // Should use the AppHost directory .venv since it only exists there AssertPythonCommandPath(appHostVenvPath, pythonProjectResource.Command); @@ -606,13 +610,13 @@ public void WithVirtualEnvironment_PrefersAppDirectoryWhenVenvExistsInBoth() try { var scriptName = "main.py"; - var resourceBuilder = builder.AddPythonScript("pythonProject", appDirName, scriptName); + var resourceBuilder = builder.AddPythonApp("pythonProject", appDirName, scriptName); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); // Should prefer the app directory .venv when it exists in both locations AssertPythonCommandPath(appVenvPath, pythonProjectResource.Command); @@ -640,13 +644,13 @@ public void WithVirtualEnvironment_DefaultsToAppDirectoryWhenVenvExistsInNeither // Don't create .venv in either directory var scriptName = "main.py"; - var resourceBuilder = builder.AddPythonScript("pythonProject", tempAppDir.Path, scriptName); + var resourceBuilder = builder.AddPythonApp("pythonProject", tempAppDir.Path, scriptName); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); // Should default to app directory when it doesn't exist in either location var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path)); @@ -678,14 +682,14 @@ public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim() 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) + var resourceBuilder = builder.AddPythonApp("pythonProject", appDirName, scriptName) .WithVirtualEnvironment("custom-venv"); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); // Should use the explicitly specified path, NOT the AppHost .venv AssertPythonCommandPath(customVenvPath, pythonProjectResource.Command); @@ -705,64 +709,75 @@ public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim() } [Fact] - public void WithUvEnvironment_CreatesUvEnvironmentResource() + public void WithUv_CreatesUvEnvironmentResource() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); var scriptName = "main.py"; - builder.AddPythonScript("pythonProject", tempDir.Path, scriptName) - .WithUvEnvironment(); + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) + .WithUv(); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var uvEnvironmentResource = appModel.Resources.OfType().Single(); - Assert.Equal("pythonProject-uv-environment", uvEnvironmentResource.Name); - Assert.Equal("uv", uvEnvironmentResource.Command); + // Verify the installer resource exists + var installerResource = appModel.Resources.OfType().Single(); + Assert.Equal("pythonProject-installer", installerResource.Name); var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path)); - Assert.Equal(expectedProjectDirectory, uvEnvironmentResource.WorkingDirectory); + Assert.Equal(expectedProjectDirectory, installerResource.WorkingDirectory); + + // Verify the package manager annotation + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("uv", packageManager.ExecutableName); + + // Verify the install command annotation + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation)); + var arg = Assert.Single(installAnnotation.Args); + Assert.Equal("sync", arg); } [Fact] - public async Task WithUvEnvironment_AddsUvSyncArgument() + public async Task WithUv_AddsUvSyncArgument() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); var scriptName = "main.py"; - builder.AddPythonScript("pythonProject", tempDir.Path, scriptName) - .WithUvEnvironment(); + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) + .WithUv(); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); - var uvEnvironmentResource = appModel.Resources.OfType().Single(); - var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(uvEnvironmentResource, TestServiceProvider.Instance); - - Assert.Single(commandArguments); - Assert.Equal("sync", commandArguments[0]); + // Verify the install command annotation has the correct args + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation)); + var arg = Assert.Single(installAnnotation.Args); + Assert.Equal("sync", arg); } [Fact] - public void WithUvEnvironment_AddsWaitForCompletionRelationship() + public async Task WithUv_AddsWaitForCompletionRelationship() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); var scriptName = "main.py"; - builder.AddPythonScript("pythonProject", tempDir.Path, scriptName) - .WithUvEnvironment(); + builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) + .WithUv(); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); + // Manually trigger BeforeStartEvent to wire up wait dependencies + await PublishBeforeStartEventAsync(app); + var pythonAppResource = appModel.Resources.OfType().Single(); - var uvEnvironmentResource = appModel.Resources.OfType().Single(); + var uvEnvironmentResource = appModel.Resources.OfType().Single(); var waitAnnotations = pythonAppResource.Annotations.OfType(); var waitForCompletionAnnotation = Assert.Single(waitAnnotations); @@ -771,44 +786,105 @@ public void WithUvEnvironment_AddsWaitForCompletionRelationship() } [Fact] - public void WithUvEnvironment_ThrowsOnNullBuilder() + public void WithUv_ThrowsOnNullBuilder() { IResourceBuilder builder = null!; var exception = Assert.Throws(() => - builder.WithUvEnvironment()); + builder.WithUv()); Assert.Equal("builder", exception.ParamName); } [Fact] - public void WithUvEnvironment_IsIdempotent() + public void WithUv_IsIdempotent() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); var scriptName = "main.py"; - // Call WithUvEnvironment twice - var pythonBuilder = builder.AddPythonScript("pythonProject", tempDir.Path, scriptName) - .WithUvEnvironment() - .WithUvEnvironment(); + // Call WithUv twice + var pythonBuilder = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) + .WithUv() + .WithUv(); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); // Verify that only one UV environment resource was created - var uvEnvironmentResource = appModel.Resources.OfType().Single(); - Assert.Equal("pythonProject-uv-environment", uvEnvironmentResource.Name); + var uvEnvironmentResource = appModel.Resources.OfType().Single(); + Assert.Equal("pythonProject-installer", uvEnvironmentResource.Name); + } + + [Fact] + public void WithPip_AfterWithUv_ReplacesPackageManager() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var scriptName = "main.py"; + + // Call WithUv then WithPip - WithPip should replace WithUv + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) + .WithUv() + .WithPip(); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify that only one installer resource was created + var installerResource = appModel.Resources.OfType().Single(); + Assert.Equal("pythonProject-installer", installerResource.Name); + + // Verify that pip is the active package manager (not uv) + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Contains("pip", packageManager.ExecutableName); + + // Verify the install command is for pip (not uv sync) + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation)); + Assert.Equal("install", installAnnotation.Args[0]); + Assert.Equal("-r", installAnnotation.Args[1]); + Assert.Equal("requirements.txt", installAnnotation.Args[2]); } [Fact] - public void AddPythonScript_CreatesResourceWithScriptEntrypoint() + public void WithUv_AfterWithPip_ReplacesPackageManager() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); - var pythonBuilder = builder.AddPythonScript("python-script", tempDir.Path, "main.py"); + var scriptName = "main.py"; + + // Call WithPip then WithUv - WithUv should replace WithPip + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) + .WithPip() + .WithUv(); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify that only one installer resource was created + var installerResource = appModel.Resources.OfType().Single(); + Assert.Equal("pythonProject-installer", installerResource.Name); + + // Verify that uv is the active package manager (not pip) + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("uv", packageManager.ExecutableName); + + // Verify the install command is for uv (not pip install) + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation)); + var arg = Assert.Single(installAnnotation.Args); + Assert.Equal("sync", arg); + } + + [Fact] + public void AddPythonApp_CreatesResourceWithScriptEntrypoint() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var pythonBuilder = builder.AddPythonApp("python-script", tempDir.Path, "main.py"); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); @@ -860,20 +936,20 @@ public void AddPythonExecutable_CreatesResourceWithExecutableEntrypoint() } [Fact] - public async Task AddPythonScript_SetsCorrectCommandAndArguments() + public async Task AddPythonApp_SetsCorrectCommandAndArguments() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); var scriptName = "main.py"; - builder.AddPythonScript("pythonProject", tempDir.Path, scriptName); + builder.AddPythonApp("pythonProject", tempDir.Path, scriptName); var app = builder.Build(); var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); Assert.Equal("pythonProject", pythonProjectResource.Name); @@ -909,7 +985,7 @@ public async Task AddPythonModule_SetsCorrectCommandAndArguments() var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path)); @@ -943,7 +1019,7 @@ public async Task AddPythonExecutable_SetsCorrectCommandAndArguments() var appModel = app.Services.GetRequiredService(); var executableResources = appModel.GetExecutableResources(); - var pythonProjectResource = Assert.Single(executableResources); + var pythonProjectResource = Assert.Single(executableResources.OfType()); var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path)); @@ -987,12 +1063,12 @@ public async Task AddPythonModule_WithArgs_AddsArgumentsCorrectly() } [Fact] - public async Task AddPythonScript_WithArgs_AddsArgumentsCorrectly() + public async Task AddPythonApp_WithArgs_AddsArgumentsCorrectly() { using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); - var pythonBuilder = builder.AddPythonScript("python-app", tempDir.Path, "main.py") + var pythonBuilder = builder.AddPythonApp("python-app", tempDir.Path, "main.py") .WithArgs("arg1", "arg2"); var app = builder.Build(); @@ -1036,7 +1112,7 @@ public async Task WithEntrypoint_ChangesEntrypointTypeAndValue() using var tempDir = new TempDirectory(); // Start with a script - var pythonBuilder = builder.AddPythonScript("python-app", tempDir.Path, "main.py") + var pythonBuilder = builder.AddPythonApp("python-app", tempDir.Path, "main.py") .WithArgs("arg1", "arg2"); // Change to a module @@ -1070,7 +1146,7 @@ public void WithEntrypoint_UpdatesCommandForExecutableType() using var tempDir = new TempDirectory(); // Start with a script - var pythonBuilder = builder.AddPythonScript("python-app", tempDir.Path, "main.py"); + var pythonBuilder = builder.AddPythonApp("python-app", tempDir.Path, "main.py"); // Get the initial command (should be python executable) var initialCommand = pythonBuilder.Resource.Command; @@ -1117,7 +1193,7 @@ public void WithEntrypoint_ThrowsOnNullOrEmptyEntrypoint() using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); - var resourceBuilder = builder.AddPythonScript("pythonProject", tempDir.Path, "main.py"); + var resourceBuilder = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py"); var nullException = Assert.Throws(() => resourceBuilder.WithEntrypoint(EntrypointType.Module, null!)); @@ -1129,7 +1205,7 @@ public void WithEntrypoint_ThrowsOnNullOrEmptyEntrypoint() } [Fact] - public async Task WithUvEnvironment_GeneratesDockerfileInPublishMode() + public async Task WithUv_GeneratesDockerfileInPublishMode() { using var sourceDir = new TempDirectory(); using var outputDir = new TempDirectory(); @@ -1166,14 +1242,14 @@ public async Task WithUvEnvironment_GeneratesDockerfileInPublishMode() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest"); // Add Python resources with different entrypoint types - builder.AddPythonScript("script-app", projectDirectory, "main.py") - .WithUvEnvironment(); + builder.AddPythonApp("script-app", projectDirectory, "main.py") + .WithUv(); builder.AddPythonModule("module-app", projectDirectory, "mymodule") - .WithUvEnvironment(); + .WithUv(); builder.AddPythonExecutable("executable-app", projectDirectory, "pytest") - .WithUvEnvironment(); + .WithUv(); var app = builder.Build(); @@ -1199,7 +1275,7 @@ await Verify(scriptDockerfileContent) } [Fact] - public async Task WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock() + public async Task WithUv_GeneratesDockerfileInPublishMode_WithoutUvLock() { using var sourceDir = new TempDirectory(); using var outputDir = new TempDirectory(); @@ -1231,14 +1307,14 @@ public async Task WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLo using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest"); // Add Python resources with different entrypoint types - builder.AddPythonScript("script-app", projectDirectory, "main.py") - .WithUvEnvironment(); + builder.AddPythonApp("script-app", projectDirectory, "main.py") + .WithUv(); builder.AddPythonModule("module-app", projectDirectory, "mymodule") - .WithUvEnvironment(); + .WithUv(); builder.AddPythonExecutable("executable-app", projectDirectory, "pytest") - .WithUvEnvironment(); + .WithUv(); var app = builder.Build(); @@ -1290,7 +1366,7 @@ public async Task WithDebugSupport_RemovesScriptArgumentForScriptEntrypoint() Directory.CreateDirectory(virtualEnvironmentPath); var scriptPath = "main.py"; - var pythonApp = builder.AddPythonScript("myapp", appDirectory, scriptPath) + var pythonApp = builder.AddPythonApp("myapp", appDirectory, scriptPath) .WithVirtualEnvironment(virtualEnvironmentPath) .WithArgs("arg1", "arg2"); @@ -1328,7 +1404,7 @@ public async Task WithDebugSupport_DoesntRemoveScriptArgumentForScriptEntrypoint Directory.CreateDirectory(virtualEnvironmentPath); var scriptPath = "main.py"; - var pythonApp = builder.AddPythonScript("myapp", appDirectory, scriptPath) + var pythonApp = builder.AddPythonApp("myapp", appDirectory, scriptPath) .WithVirtualEnvironment(virtualEnvironmentPath) .WithArgs("arg1", "arg2"); @@ -1461,7 +1537,7 @@ public async Task PythonApp_SetsPythonUtf8EnvironmentVariable_OnWindowsInRunMode using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); - var pythonApp = builder.AddPythonScript("pythonProject", tempDir.Path, "main.py"); + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py"); var app = builder.Build(); var environmentVariables = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( @@ -1483,7 +1559,7 @@ public async Task PythonApp_DoesNotSetPythonUtf8EnvironmentVariable_OnNonWindows using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); - var pythonApp = builder.AddPythonScript("pythonProject", tempDir.Path, "main.py"); + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py"); var app = builder.Build(); var environmentVariables = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( @@ -1501,7 +1577,7 @@ public async Task PythonApp_DoesNotSetPythonUtf8EnvironmentVariable_InPublishMod using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithTestAndResourceLogging(outputHelper); using var tempDir = new TempDirectory(); - var pythonApp = builder.AddPythonScript("pythonProject", tempDir.Path, "main.py"); + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py"); var app = builder.Build(); var environmentVariables = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync( @@ -1512,7 +1588,7 @@ public async Task PythonApp_DoesNotSetPythonUtf8EnvironmentVariable_InPublishMod } [Fact] - public async Task WithUvEnvironment_CustomBaseImages_GeneratesDockerfileWithCustomImages() + public async Task WithUv_CustomBaseImages_GeneratesDockerfileWithCustomImages() { using var sourceDir = new TempDirectory(); using var outputDir = new TempDirectory(); @@ -1547,8 +1623,8 @@ public async Task WithUvEnvironment_CustomBaseImages_GeneratesDockerfileWithCust using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest"); // Add Python resource with custom base images - builder.AddPythonScript("custom-images-app", projectDirectory, "main.py") - .WithUvEnvironment() + builder.AddPythonApp("custom-images-app", projectDirectory, "main.py") + .WithUv() .WithDockerfileBaseImage( buildImage: "ghcr.io/astral-sh/uv:python3.13-bookworm", runtimeImage: "python:3.13-slim"); @@ -1592,7 +1668,7 @@ public async Task FallbackDockerfile_GeneratesDockerfileWithoutUv_WithRequiremen using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest"); // Add Python resources without UV environment - builder.AddPythonScript("script-app", projectDirectory, "main.py"); + builder.AddPythonApp("script-app", projectDirectory, "main.py"); var app = builder.Build(); app.Run(); @@ -1641,7 +1717,7 @@ public async Task FallbackDockerfile_GeneratesDockerfileWithoutUv_WithoutRequire using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest"); // Add Python resources without UV environment - builder.AddPythonScript("script-app", projectDirectory, "main.py"); + builder.AddPythonApp("script-app", projectDirectory, "main.py"); var app = builder.Build(); app.Run(); @@ -1685,7 +1761,7 @@ public async Task FallbackDockerfile_GeneratesDockerfileForAllEntrypointTypes() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest"); // Add Python resources with different entrypoint types, none using UV - builder.AddPythonScript("script-app", projectDirectory, "main.py"); + builder.AddPythonApp("script-app", projectDirectory, "main.py"); builder.AddPythonModule("module-app", projectDirectory, "mymodule"); builder.AddPythonExecutable("executable-app", projectDirectory, "pytest"); @@ -1720,5 +1796,486 @@ await Verify(scriptDockerfileContent) .AppendContentAsFile(moduleDockerfileContent) .AppendContentAsFile(executableDockerfileContent); } + + [Fact] + public void AutoDetection_PyprojectToml_AddsPip() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var scriptName = "main.py"; + var scriptPath = Path.Combine(tempDir.Path, scriptName); + File.WriteAllText(scriptPath, "print('Hello')"); + + // Create a pyproject.toml file + var pyprojectPath = Path.Combine(tempDir.Path, "pyproject.toml"); + File.WriteAllText(pyprojectPath, "[project]\nname = \"test\""); + + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName); + + var app = builder.Build(); + + // Verify that WithPip was automatically called (pip supports pyproject.toml) + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Contains("pip", packageManager.ExecutableName); + + // Verify that the install command uses pyproject.toml + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation)); + Assert.Equal("install", installAnnotation.Args[0]); + Assert.Equal(".", installAnnotation.Args[1]); + + // Verify that WithPip created the installer resource + var appModel = app.Services.GetRequiredService(); + var installerResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(installerResource); + Assert.Equal("pythonProject-installer", installerResource.Name); + } + + [Fact] + public void AutoDetection_RequirementsTxt_AddsPip() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var scriptName = "main.py"; + var scriptPath = Path.Combine(tempDir.Path, scriptName); + File.WriteAllText(scriptPath, "print('Hello')"); + + // Create a requirements.txt file + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests==2.31.0"); + + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName); + + var app = builder.Build(); + + // Verify that WithPip was automatically called + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Contains("pip", packageManager.ExecutableName); + + // Verify that the install command uses requirements.txt + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation)); + Assert.Equal("install", installAnnotation.Args[0]); + Assert.Equal("-r", installAnnotation.Args[1]); + Assert.Equal("requirements.txt", installAnnotation.Args[2]); + + // Verify that WithPip created the installer resource + var appModel = app.Services.GetRequiredService(); + var installerResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(installerResource); + Assert.Equal("pythonProject-installer", installerResource.Name); + } + + [Fact] + public void AutoDetection_PyprojectToml_TakesPrecedenceOverRequirementsTxt() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var scriptName = "main.py"; + var scriptPath = Path.Combine(tempDir.Path, scriptName); + File.WriteAllText(scriptPath, "print('Hello')"); + + // Create both pyproject.toml and requirements.txt + var pyprojectPath = Path.Combine(tempDir.Path, "pyproject.toml"); + File.WriteAllText(pyprojectPath, "[project]\nname = \"test\""); + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests==2.31.0"); + + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName); + + var app = builder.Build(); + + // Verify that WithPip was automatically called + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Contains("pip", packageManager.ExecutableName); + + // Verify the install command uses pyproject.toml (takes precedence) + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation)); + Assert.Equal("install", installAnnotation.Args[0]); + Assert.Equal(".", installAnnotation.Args[1]); + } + + [Fact] + public void AutoDetection_NoConfigFile_DoesNotAddPackageManager() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var scriptName = "main.py"; + var scriptPath = Path.Combine(tempDir.Path, scriptName); + File.WriteAllText(scriptPath, "print('Hello')"); + + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName); + + var app = builder.Build(); + + // Verify that no package manager was automatically added + Assert.False(pythonApp.Resource.TryGetLastAnnotation(out _)); + + // Verify that no installer resource was created + var appModel = app.Services.GetRequiredService(); + var installerResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.Null(installerResource); + } + + [Fact] + public void WithVirtualEnvironment_DisableCreation_DoesNotCreateVenvCreator() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + using var tempVenvDir = new TempDirectory(); + + var scriptName = "main.py"; + var scriptPath = Path.Combine(tempDir.Path, scriptName); + File.WriteAllText(scriptPath, "print('Hello')"); + + // Add Python script with venv but disable automatic creation + builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) + .WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: false); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify that no venv creator resource was created + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.Null(venvCreatorResource); + } + + [Fact] + public void WithVirtualEnvironment_EnableCreation_CreatesVenvCreator() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + using var tempVenvDir = new TempDirectory(); + + var scriptName = "main.py"; + var scriptPath = Path.Combine(tempDir.Path, scriptName); + File.WriteAllText(scriptPath, "print('Hello')"); + + // Create a requirements.txt to trigger pip installation + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests"); + + // Add Python script with venv and enable automatic creation (default) + builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) + .WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: true); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify that a venv creator resource was created + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(venvCreatorResource); + Assert.Equal("pythonProject-venv-creator", venvCreatorResource.Name); + } + + [Fact] + public void WithVirtualEnvironment_DefaultBehavior_CreatesVenvCreator() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + using var tempVenvDir = new TempDirectory(); + + var scriptName = "main.py"; + var scriptPath = Path.Combine(tempDir.Path, scriptName); + File.WriteAllText(scriptPath, "print('Hello')"); + + // Create a requirements.txt to trigger pip installation + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests"); + + // Add Python script with venv using default behavior (createIfNotExists defaults to true) + builder.AddPythonApp("pythonProject", tempDir.Path, scriptName) + .WithVirtualEnvironment(tempVenvDir.Path); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify that a venv creator resource was created + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(venvCreatorResource); + Assert.Equal("pythonProject-venv-creator", venvCreatorResource.Name); + } + + // ===== Method Ordering Tests ===== + // These tests verify that WithPip, WithUv, and WithVirtualEnvironment work correctly in any order + + [Fact] + public void WithUv_DisablesVenvCreation_And_SetsPackageManager() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py") + .WithUv(); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify uv is the package manager + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("uv", packageManager.ExecutableName); + + // Verify NO venv creator was created (uv handles venv itself) + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.Null(venvCreatorResource); + + // Verify installer exists + var installerResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(installerResource); + } + + [Fact] + public async Task WithPip_CreatesDefaultVenv_And_WaitsForVenvCreation() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + // Create requirements.txt + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests"); + + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py") + .WithPip(); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Manually trigger BeforeStartEvent to wire up wait dependencies + await PublishBeforeStartEventAsync(app); + + // Verify pip is the package manager + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Contains("pip", packageManager.ExecutableName); + + // Verify default .venv was created + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var envAnnotation)); + Assert.NotNull(envAnnotation.VirtualEnvironment); + Assert.Contains(".venv", envAnnotation.VirtualEnvironment.VirtualEnvironmentPath); + + // Verify venv creator was created + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(venvCreatorResource); + + // Verify installer exists and waits for venv creator + var installerResource = appModel.Resources.OfType().Single(); + var installerWaits = installerResource.Annotations.OfType() + .Any(w => w.Resource == venvCreatorResource); + Assert.True(installerWaits); + } + + [Fact] + public void WithPip_ThenWithVirtualEnvironment_CreateIfNotExistsTrue_CreatesVenv() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + using var tempVenvDir = new TempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests"); + + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py") + .WithPip() + .WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: true); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify venv annotation + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var envAnnotation)); + Assert.NotNull(envAnnotation.VirtualEnvironment); + Assert.True(envAnnotation.CreateVenvIfNotExists); + + // Verify venv creator was created + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(venvCreatorResource); + } + + [Fact] + public void WithPip_ThenWithVirtualEnvironment_CreateIfNotExistsFalse_DoesNotCreateVenv() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + using var tempVenvDir = new TempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests"); + + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py") + .WithPip() + .WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: false); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify venv annotation with createIfNotExists: false + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var envAnnotation)); + Assert.NotNull(envAnnotation.VirtualEnvironment); + Assert.False(envAnnotation.CreateVenvIfNotExists); + + // Verify NO venv creator was created + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.Null(venvCreatorResource); + + // Verify installer still exists + var installerResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(installerResource); + } + + [Fact] + public async Task MethodOrdering_WithPip_WithVirtualEnvironment_CreateTrue_WithPip_CreatesVenv() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + using var tempVenvDir = new TempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests"); + + // WithPip → WithVirtualEnvironment(createIfNotExists: true) → WithPip again + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py") + .WithPip() + .WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: true) + .WithPip(); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Manually trigger BeforeStartEvent to wire up wait dependencies + await PublishBeforeStartEventAsync(app); + + // Verify venv creator was created (createIfNotExists: true persists) + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(venvCreatorResource); + + // Verify installer waits for venv creator + var installerResource = appModel.Resources.OfType().Single(); + var installerWaits = installerResource.Annotations.OfType() + .Any(w => w.Resource == venvCreatorResource); + Assert.True(installerWaits); + } + + [Fact] + public void MethodOrdering_WithPip_WithVirtualEnvironment_CreateFalse_WithPip_DoesNotCreateVenv() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + using var tempVenvDir = new TempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests"); + + // WithPip → WithVirtualEnvironment(createIfNotExists: false) → WithPip again + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py") + .WithPip() + .WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: false) + .WithPip(); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify NO venv creator was created (createIfNotExists: false persists) + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.Null(venvCreatorResource); + + // Verify installer still exists + var installerResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(installerResource); + } + + [Fact] + public void MethodOrdering_WithPip_ThenWithUv_ReplacesPackageManager_And_DisablesVenvCreation() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests"); + + // WithPip → WithUv (uv should replace pip and disable venv creation) + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py") + .WithPip() + .WithUv(); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify uv is the package manager (replaced pip) + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Equal("uv", packageManager.ExecutableName); + + // Verify NO venv creator (uv disables venv creation) + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.Null(venvCreatorResource); + + // Verify only one installer exists + Assert.Single(appModel.Resources.OfType()); + } + + [Fact] + public void MethodOrdering_WithUv_ThenWithPip_ReplacesPackageManager_And_EnablesVenvCreation() + { + using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper); + using var tempDir = new TempDirectory(); + + var scriptPath = Path.Combine(tempDir.Path, "main.py"); + File.WriteAllText(scriptPath, "print('Hello')"); + + var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt"); + File.WriteAllText(requirementsPath, "requests"); + + // WithUv → WithPip (pip should replace uv and enable venv creation) + var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py") + .WithUv() + .WithPip(); + + var app = builder.Build(); + var appModel = app.Services.GetRequiredService(); + + // Verify pip is the package manager (replaced uv) + Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager)); + Assert.Contains("pip", packageManager.ExecutableName); + + // Verify venv creator was created (pip enables venv creation) + var venvCreatorResource = appModel.Resources.OfType().SingleOrDefault(); + Assert.NotNull(venvCreatorResource); + + // Verify only one installer exists + Assert.Single(appModel.Resources.OfType()); + } + + /// + /// Helper method to manually trigger BeforeStartEvent for tests. + /// This is needed because BeforeStartEvent is normally triggered during StartAsync(), + /// but tests often build and assert on the model without starting the application. + /// + private static async Task PublishBeforeStartEventAsync(DistributedApplication app) + { + var appModel = app.Services.GetRequiredService(); + var eventing = app.Services.GetRequiredService(); + await eventing.PublishAsync(new BeforeStartEvent(app.Services, appModel), CancellationToken.None); + } } diff --git a/tests/Aspire.Hosting.Python.Tests/AddUvicornAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddUvicornAppTests.cs index 4791a6ad2a1..34e39434336 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddUvicornAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddUvicornAppTests.cs @@ -26,7 +26,7 @@ public void AddUvicornApp_CreatesUvicornAppResource() } [Fact] - public async Task WithUvEnvironment_GeneratesDockerfileInPublishMode() + public async Task WithUv_GeneratesDockerfileInPublishMode() { using var sourceDir = new TempDirectory(); using var outputDir = new TempDirectory(); @@ -63,7 +63,7 @@ public async Task WithUvEnvironment_GeneratesDockerfileInPublishMode() using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest"); var main = builder.AddUvicornApp("main", projectDirectory, "main.py") - .WithUvEnvironment(); + .WithUv(); var sourceFiles = builder.AddResource(new MyFilesContainer("exe", "exe", ".")) .PublishAsDockerFile(c => diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode#00.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode#00.verified.txt similarity index 100% rename from tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode#00.verified.txt rename to tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode#00.verified.txt diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode#01.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode#01.verified.txt similarity index 100% rename from tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode#01.verified.txt rename to tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode#01.verified.txt diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode#02.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode#02.verified.txt similarity index 100% rename from tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode#02.verified.txt rename to tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode#02.verified.txt diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#00.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode_WithoutUvLock#00.verified.txt similarity index 100% rename from tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#00.verified.txt rename to tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode_WithoutUvLock#00.verified.txt diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#01.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode_WithoutUvLock#01.verified.txt similarity index 100% rename from tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#01.verified.txt rename to tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode_WithoutUvLock#01.verified.txt diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#02.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode_WithoutUvLock#02.verified.txt similarity index 100% rename from tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode_WithoutUvLock#02.verified.txt rename to tests/Aspire.Hosting.Python.Tests/Snapshots/AddPythonAppTests.WithUv_GeneratesDockerfileInPublishMode_WithoutUvLock#02.verified.txt diff --git a/tests/Aspire.Hosting.Python.Tests/Snapshots/AddUvicornAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode.verified.txt b/tests/Aspire.Hosting.Python.Tests/Snapshots/AddUvicornAppTests.WithUv_GeneratesDockerfileInPublishMode.verified.txt similarity index 100% rename from tests/Aspire.Hosting.Python.Tests/Snapshots/AddUvicornAppTests.WithUvEnvironment_GeneratesDockerfileInPublishMode.verified.txt rename to tests/Aspire.Hosting.Python.Tests/Snapshots/AddUvicornAppTests.WithUv_GeneratesDockerfileInPublishMode.verified.txt