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