diff --git a/src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.cs b/src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.cs
new file mode 100644
index 00000000000..b27a478b95b
--- /dev/null
+++ b/src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.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 environment variables to be set for Poetry operations.
+///
+/// The environment variables to set for Poetry.
+internal sealed class PoetryEnvironmentAnnotation(IReadOnlyDictionary environmentVariables) : IResourceAnnotation
+{
+ ///
+ /// Gets the environment variables to be set for Poetry operations.
+ ///
+ public IReadOnlyDictionary EnvironmentVariables { get; } = environmentVariables;
+}
diff --git a/src/Aspire.Hosting.Python/PoetryInstallationManager.cs b/src/Aspire.Hosting.Python/PoetryInstallationManager.cs
new file mode 100644
index 00000000000..e96bccf0bcb
--- /dev/null
+++ b/src/Aspire.Hosting.Python/PoetryInstallationManager.cs
@@ -0,0 +1,46 @@
+// 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.Utils;
+using Microsoft.Extensions.Logging;
+
+namespace Aspire.Hosting.Python;
+
+///
+/// Validates that the poetry command is available on the system.
+///
+#pragma warning disable ASPIREINTERACTION001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
+internal sealed class PoetryInstallationManager : RequiredCommandValidator
+{
+ private string? _resolvedCommandPath;
+
+ public PoetryInstallationManager(
+ IInteractionService interactionService,
+ ILogger logger)
+ : base(interactionService, logger)
+ {
+ }
+
+ ///
+ /// Ensures poetry is installed/available. This method is safe for concurrent callers;
+ /// only one validation will run at a time.
+ ///
+ /// Whether to throw an exception if poetry is not found. Default is true.
+ /// Cancellation token.
+ public Task EnsureInstalledAsync(bool throwOnFailure = true, CancellationToken cancellationToken = default)
+ {
+ SetThrowOnFailure(throwOnFailure);
+ return RunAsync(cancellationToken);
+ }
+
+ protected override string GetCommandPath() => "poetry";
+
+ protected override Task OnValidatedAsync(string resolvedCommandPath, CancellationToken cancellationToken)
+ {
+ _resolvedCommandPath = resolvedCommandPath;
+ return Task.CompletedTask;
+ }
+
+ protected override string? GetHelpLink() => "https://python-poetry.org/docs/#installation";
+}
+#pragma warning restore ASPIREINTERACTION001
diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
index a0ff10b660a..c87138cb2fa 100644
--- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
+++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
@@ -366,7 +366,12 @@ private static IResourceBuilder AddPythonAppCore(
ArgumentNullException.ThrowIfNull(virtualEnvironmentPath);
// Register Python environment validation services (once per builder)
- builder.Services.TryAddSingleton();
+ if (!builder.Services.IsReadOnly)
+ {
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddSingleton();
+ builder.Services.TryAddSingleton();
+ }
// When using the default virtual environment path, look for existing virtual environments
// in multiple locations: app directory first, then AppHost directory as fallback
var resolvedVenvPath = virtualEnvironmentPath;
@@ -478,14 +483,21 @@ private static IResourceBuilder AddPythonAppCore(
var entrypointType = entrypointAnnotation.Type;
var entrypoint = entrypointAnnotation.Entrypoint;
- // Check if using UV by looking at the package manager annotation
- var isUsingUv = context.Resource.TryGetLastAnnotation(out var pkgMgr) &&
- pkgMgr.ExecutableName == "uv";
+ // Check which package manager is being used
+ string? packageManagerName = null;
+ if (context.Resource.TryGetLastAnnotation(out var pkgMgr))
+ {
+ packageManagerName = pkgMgr.ExecutableName;
+ }
- if (isUsingUv)
+ if (packageManagerName == "uv")
{
GenerateUvDockerfile(context, resource, pythonVersion, entrypointType, entrypoint);
}
+ else if (packageManagerName == "poetry")
+ {
+ GeneratePoetryDockerfile(context, resource, pythonVersion, entrypointType, entrypoint);
+ }
else
{
GenerateFallbackDockerfile(context, resource, pythonVersion, entrypointType, entrypoint);
@@ -646,6 +658,111 @@ private static void GenerateUvDockerfile(DockerfileBuilderCallbackContext contex
}
}
+ private static void GeneratePoetryDockerfile(DockerfileBuilderCallbackContext context, PythonAppResource resource,
+ string pythonVersion, EntrypointType entrypointType, string entrypoint)
+ {
+ // Check if poetry.lock exists in the working directory
+ var poetryLockPath = Path.Combine(resource.WorkingDirectory, "poetry.lock");
+ var hasPoetryLock = File.Exists(poetryLockPath);
+
+ // Get custom base images from annotation, if present
+ context.Resource.TryGetLastAnnotation(out var baseImageAnnotation);
+ var buildImage = baseImageAnnotation?.BuildImage ?? $"python:{pythonVersion}-slim-bookworm";
+ var runtimeImage = baseImageAnnotation?.RuntimeImage ?? $"python:{pythonVersion}-slim-bookworm";
+
+ var builderStage = context.Builder
+ .From(buildImage, "builder")
+ .EmptyLine()
+ .Comment("Install Poetry")
+ .Run("pip install --no-cache-dir poetry")
+ .EmptyLine()
+ .Comment("Configure Poetry to create virtual environments in the project directory")
+ .Env("POETRY_VIRTUALENVS_IN_PROJECT", "true")
+ .Env("POETRY_NO_INTERACTION", "1")
+ .EmptyLine()
+ .WorkDir("/app")
+ .EmptyLine();
+
+ if (hasPoetryLock)
+ {
+ // If poetry.lock exists, use locked mode for reproducible builds
+ builderStage
+ .Comment("Install dependencies first for better layer caching")
+ .Comment("Copy only pyproject.toml and poetry.lock for dependency installation")
+ .Copy("pyproject.toml", "/app/")
+ .Copy("poetry.lock", "/app/")
+ .EmptyLine()
+ .Comment("Install dependencies (no dev dependencies)")
+ .Run("poetry install --no-interaction --no-root --only main")
+ .EmptyLine()
+ .Comment("Copy the rest of the application source")
+ .Copy(".", "/app")
+ .EmptyLine()
+ .Comment("Install the project itself")
+ .Run("poetry install --no-interaction --only-root");
+ }
+ else
+ {
+ // If poetry.lock doesn't exist, copy pyproject.toml and generate lock file
+ builderStage
+ .Comment("Copy pyproject.toml to install dependencies")
+ .Copy("pyproject.toml", "/app/")
+ .EmptyLine()
+ .Comment("Install dependencies and generate lock file")
+ .Run("poetry install --no-interaction --no-root --only main")
+ .EmptyLine()
+ .Comment("Copy the rest of the application source")
+ .Copy(".", "/app")
+ .EmptyLine()
+ .Comment("Install the project itself")
+ .Run("poetry install --no-interaction --only-root");
+ }
+
+ var logger = context.Services.GetService>();
+ context.Builder.AddContainerFilesStages(context.Resource, logger);
+
+ var runtimeBuilder = context.Builder
+ .From(runtimeImage, "app")
+ .EmptyLine()
+ .AddContainerFiles(context.Resource, "/app", logger)
+ .Comment("------------------------------")
+ .Comment("🚀 Runtime stage")
+ .Comment("------------------------------")
+ .Comment("Create non-root user for security")
+ .Run("groupadd --system --gid 999 appuser && useradd --system --gid 999 --uid 999 --create-home appuser")
+ .EmptyLine()
+ .Comment("Copy the application and virtual environment from builder")
+ .CopyFrom(builderStage.StageName!, "/app", "/app", "appuser:appuser")
+ .EmptyLine()
+ .Comment("Add virtual environment to PATH and set VIRTUAL_ENV")
+ .Env("PATH", "/app/.venv/bin:${PATH}")
+ .Env("VIRTUAL_ENV", "/app/.venv")
+ .Env("PYTHONDONTWRITEBYTECODE", "1")
+ .Env("PYTHONUNBUFFERED", "1")
+ .EmptyLine()
+ .Comment("Use the non-root user to run the application")
+ .User("appuser")
+ .EmptyLine()
+ .Comment("Set working directory")
+ .WorkDir("/app")
+ .EmptyLine()
+ .Comment("Run the application");
+
+ // Set the appropriate entrypoint and command based on entrypoint type
+ switch (entrypointType)
+ {
+ case EntrypointType.Script:
+ runtimeBuilder.Entrypoint(["python", entrypoint]);
+ break;
+ case EntrypointType.Module:
+ runtimeBuilder.Entrypoint(["python", "-m", entrypoint]);
+ break;
+ case EntrypointType.Executable:
+ runtimeBuilder.Entrypoint([entrypoint]);
+ break;
+ }
+ }
+
private static void GenerateFallbackDockerfile(DockerfileBuilderCallbackContext context, PythonAppResource resource,
string pythonVersion, EntrypointType entrypointType, string entrypoint)
{
@@ -1258,9 +1375,6 @@ public static IResourceBuilder WithUv(this IResourceBuilder builder, bo
{
ArgumentNullException.ThrowIfNull(builder);
- // Register UV validation service
- builder.ApplicationBuilder.Services.TryAddSingleton();
-
// Default args: sync only (uv will auto-detect Python and dependencies from pyproject.toml)
args ??= ["sync"];
@@ -1276,6 +1390,116 @@ public static IResourceBuilder WithUv(this IResourceBuilder builder, bo
return builder;
}
+ ///
+ /// Configures the Python resource to use Poetry as the package manager.
+ ///
+ /// The type of the Python application resource, must derive from .
+ /// The resource builder.
+ /// When true (default), automatically runs poetry install before the application starts. When false, only sets the package manager annotation without creating an installer resource.
+ /// Additional arguments appended to the poetry install command (after --no-interaction).
+ /// Extra environment variables applied to the Poetry restore step. These can be used to configure Poetry behavior such as POETRY_VIRTUALENVS_IN_PROJECT or POETRY_VIRTUALENVS_PATH.
+ /// A reference to the for method chaining.
+ ///
+ ///
+ /// This method creates a child resource that runs poetry install --no-interaction in the working directory of the Python application.
+ /// The Python application will wait for this resource to complete successfully before starting.
+ ///
+ ///
+ /// Poetry (https://python-poetry.org/) is a Python package and dependency manager that handles virtual environment creation.
+ /// Aspire does not set Poetry environment variables by default, allowing Poetry to use its own default configuration.
+ /// You can customize Poetry's behavior by passing environment variables via the parameter, such as
+ /// setting POETRY_VIRTUALENVS_IN_PROJECT=true to create the virtual environment in the project directory.
+ ///
+ ///
+ /// Calling this method will replace any previously configured package manager (such as pip or uv).
+ ///
+ ///
+ ///
+ /// Add a Python app with automatic Poetry package installation:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ ///
+ /// var python = builder.AddPythonApp("api", "../python-api", "main.py")
+ /// .WithPoetry() // Automatically runs 'poetry install --no-interaction'
+ /// .WithHttpEndpoint(port: 5000);
+ ///
+ /// builder.Build().Run();
+ ///
+ ///
+ ///
+ /// Add a Python app with custom Poetry install arguments:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ ///
+ /// var python = builder.AddPythonApp("api", "../python-api", "main.py")
+ /// .WithPoetry(installArgs: ["--no-root", "--sync"])
+ /// .WithHttpEndpoint(port: 5000);
+ ///
+ /// builder.Build().Run();
+ ///
+ ///
+ ///
+ /// Configure Poetry's virtual environment location:
+ ///
+ /// var builder = DistributedApplication.CreateBuilder(args);
+ ///
+ /// var python = builder.AddPythonApp("api", "../python-api", "main.py")
+ /// .WithPoetry(env: [("POETRY_VIRTUALENVS_IN_PROJECT", "true")])
+ /// .WithHttpEndpoint(port: 5000);
+ ///
+ /// builder.Build().Run();
+ ///
+ ///
+ /// Thrown when is null.
+ public static IResourceBuilder WithPoetry(
+ this IResourceBuilder builder,
+ bool install = true,
+ string[]? installArgs = null,
+ (string key, string value)[]? env = 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");
+ }
+
+ // Build base install command: poetry install --no-interaction
+ var baseInstallArgs = new List { "install", "--no-interaction" };
+
+ // Append user-provided install args
+ if (installArgs != null)
+ {
+ baseInstallArgs.AddRange(installArgs);
+ }
+
+ // Store Poetry environment variables in an annotation for later use
+ if (env != null && env.Length > 0)
+ {
+ var envDict = new Dictionary(env.Length);
+ foreach (var (key, value) in env)
+ {
+ envDict[key] = value;
+ }
+ builder.WithAnnotation(new PoetryEnvironmentAnnotation(envDict), ResourceAnnotationMutationBehavior.Replace);
+ }
+
+ builder
+ .WithAnnotation(new PythonPackageManagerAnnotation("poetry"), ResourceAnnotationMutationBehavior.Replace)
+ .WithAnnotation(new PythonInstallCommandAnnotation([.. baseInstallArgs]), ResourceAnnotationMutationBehavior.Replace);
+
+ AddInstaller(builder, install);
+
+ // Poetry handles venv creation, so remove any existing venv creator
+ RemoveVenvCreator(builder);
+
+ return builder;
+ }
+
private static bool IsPythonCommandAvailable(string command)
{
var pathVariable = Environment.GetEnvironmentVariable("PATH");
@@ -1349,16 +1573,24 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest();
- // Add validation for the installer command (uv or python)
+ // Add validation for the installer command (uv, poetry, or python)
installerBuilder.OnBeforeResourceStarted(static async (installerResource, e, ct) =>
{
// Check which command this installer is using (set by BeforeStartEvent)
- if (installerResource.TryGetLastAnnotation(out var executable) &&
- executable.Command == "uv")
+ if (installerResource.TryGetLastAnnotation(out var executable))
{
- // Validate that uv is installed - don't throw so the app fails as it normally would
- var uvInstallationManager = e.Services.GetRequiredService();
- await uvInstallationManager.EnsureInstalledAsync(throwOnFailure: false, ct).ConfigureAwait(false);
+ if (executable.Command == "uv")
+ {
+ // Validate that uv is installed - don't throw so the app fails as it normally would
+ var uvInstallationManager = e.Services.GetRequiredService();
+ await uvInstallationManager.EnsureInstalledAsync(throwOnFailure: false, ct).ConfigureAwait(false);
+ }
+ else if (executable.Command == "poetry")
+ {
+ // Validate that poetry is installed - don't throw so the app fails as it normally would
+ var poetryInstallationManager = e.Services.GetRequiredService();
+ await poetryInstallationManager.EnsureInstalledAsync(throwOnFailure: false, ct).ConfigureAwait(false);
+ }
}
// For other package managers (pip, etc.), Python validation happens via PythonVenvCreatorResource
});
@@ -1380,6 +1612,24 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w
.WithWorkingDirectory(builder.Resource.WorkingDirectory)
.WithArgs(installCommand.Args);
+ // Add Poetry-specific environment variables
+ if (packageManager.ExecutableName == "poetry")
+ {
+ installerBuilder.WithEnvironment(ctx =>
+ {
+ // Apply user-specified environment variable overrides only
+ // Do not set POETRY_VIRTUALENVS_IN_PROJECT or POETRY_VIRTUALENVS_PATH by default
+ // Let Poetry use its own defaults
+ if (builder.Resource.TryGetLastAnnotation(out var poetryEnv))
+ {
+ foreach (var kvp in poetryEnv.EnvironmentVariables)
+ {
+ ctx.EnvironmentVariables[kvp.Key] = kvp.Value;
+ }
+ }
+ });
+ }
+
return Task.CompletedTask;
});
@@ -1506,14 +1756,14 @@ private static void SetupDependencies(IDistributedApplicationBuilder builder, Py
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)
+ // Check if we're using a package manager that handles venv creation itself (uv, poetry)
+ if (builder.Resource.TryGetLastAnnotation(out var pkgMgr))
{
- // UV handles venv creation, we don't need to create it
- return false;
+ // Package managers that handle their own venv creation
+ if (pkgMgr.ExecutableName is "uv" or "poetry")
+ {
+ return false;
+ }
}
// Get the virtual environment path
diff --git a/src/Aspire.Hosting.Python/api/Aspire.Hosting.Python.cs b/src/Aspire.Hosting.Python/api/Aspire.Hosting.Python.cs
index 99aaa9a24da..030e6bf5266 100644
--- a/src/Aspire.Hosting.Python/api/Aspire.Hosting.Python.cs
+++ b/src/Aspire.Hosting.Python/api/Aspire.Hosting.Python.cs
@@ -13,6 +13,8 @@ public static partial class PythonAppResourceBuilderExtensions
public static ApplicationModel.IResourceBuilder AddPythonApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath, string virtualEnvironmentPath, params string[] scriptArgs) { throw null; }
public static ApplicationModel.IResourceBuilder AddPythonApp(this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath, params string[] scriptArgs) { throw null; }
+
+ public static ApplicationModel.IResourceBuilder WithPoetry(this ApplicationModel.IResourceBuilder builder, bool install = true, string[]? installArgs = null, (string key, string value)[]? env = null) where T : Python.PythonAppResource { throw null; }
}
[System.Obsolete("PythonProjectResource is deprecated. Please use PythonAppResource instead.")]
diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
index 8a1fea29671..b362cd4c9c8 100644
--- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
+++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs
@@ -2287,6 +2287,200 @@ public void MethodOrdering_WithUv_ThenWithPip_ReplacesPackageManager_And_Enables
Assert.Single(appModel.Resources.OfType());
}
+ [Fact]
+ public void WithPoetry_CreatesPoetryInstallerResource()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
+ using var tempDir = new TempDirectory();
+
+ var scriptName = "main.py";
+
+ var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
+ .WithPoetry();
+
+ var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ // 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, installerResource.WorkingDirectory);
+
+ // Verify the package manager annotation
+ Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager));
+ Assert.Equal("poetry", packageManager.ExecutableName);
+
+ // Verify the install command annotation
+ Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation));
+ Assert.Equal(2, installAnnotation.Args.Length);
+ Assert.Equal("install", installAnnotation.Args[0]);
+ Assert.Equal("--no-interaction", installAnnotation.Args[1]);
+ }
+
+ [Fact]
+ public void WithPoetry_WithCustomInstallArgs_AppendsArgs()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
+ using var tempDir = new TempDirectory();
+
+ var scriptName = "main.py";
+
+ var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
+ .WithPoetry(installArgs: ["--no-root", "--sync"]);
+
+ var app = builder.Build();
+
+ // Verify the install command annotation has custom args
+ Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation));
+ Assert.Equal(4, installAnnotation.Args.Length);
+ Assert.Equal("install", installAnnotation.Args[0]);
+ Assert.Equal("--no-interaction", installAnnotation.Args[1]);
+ Assert.Equal("--no-root", installAnnotation.Args[2]);
+ Assert.Equal("--sync", installAnnotation.Args[3]);
+ }
+
+ [Fact]
+ public void WithPoetry_WithEnvironmentVariables_StoresAnnotation()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
+ using var tempDir = new TempDirectory();
+
+ var scriptName = "main.py";
+
+ var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
+ .WithPoetry(env: [("POETRY_VIRTUALENVS_IN_PROJECT", "false"), ("POETRY_HTTP_TIMEOUT", "60")]);
+
+ var app = builder.Build();
+
+ // Verify the Poetry environment annotation exists with the custom environment variables
+ Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var poetryEnv));
+ Assert.Equal(2, poetryEnv.EnvironmentVariables.Count);
+ Assert.Equal("false", poetryEnv.EnvironmentVariables["POETRY_VIRTUALENVS_IN_PROJECT"]);
+ Assert.Equal("60", poetryEnv.EnvironmentVariables["POETRY_HTTP_TIMEOUT"]);
+ }
+
+ [Fact]
+ public void WithPoetry_InstallFalse_DoesNotCreateInstaller()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
+ using var tempDir = new TempDirectory();
+
+ var scriptName = "main.py";
+
+ var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
+ .WithPoetry(install: false);
+
+ var app = builder.Build();
+ var appModel = app.Services.GetRequiredService();
+
+ // Verify the installer resource does not exist
+ var installerResource = appModel.Resources.OfType().SingleOrDefault();
+ Assert.Null(installerResource);
+
+ // Verify the package manager annotation still exists
+ Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager));
+ Assert.Equal("poetry", packageManager.ExecutableName);
+ }
+
+ [Fact]
+ public void WithPoetry_AfterWithUv_ReplacesPackageManager()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
+ using var tempDir = new TempDirectory();
+
+ var scriptName = "main.py";
+
+ // Call WithUv then WithPoetry - WithPoetry should replace WithUv
+ var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
+ .WithUv()
+ .WithPoetry();
+
+ 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 poetry is the active package manager (not uv)
+ Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager));
+ Assert.Equal("poetry", packageManager.ExecutableName);
+
+ // Verify the install command is for poetry (not uv sync)
+ Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation));
+ Assert.Equal("install", installAnnotation.Args[0]);
+ Assert.Equal("--no-interaction", installAnnotation.Args[1]);
+ }
+
+ [Fact]
+ public void WithPoetry_AfterWithPip_ReplacesPackageManager()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
+ using var tempDir = new TempDirectory();
+
+ var scriptName = "main.py";
+
+ // Call WithPip then WithPoetry - WithPoetry should replace WithPip
+ var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
+ .WithPip()
+ .WithPoetry();
+
+ 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 poetry is the active package manager (not pip)
+ Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var packageManager));
+ Assert.Equal("poetry", packageManager.ExecutableName);
+
+ // Verify the install command is for poetry (not pip install)
+ Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var installAnnotation));
+ Assert.Equal("install", installAnnotation.Args[0]);
+ Assert.Equal("--no-interaction", installAnnotation.Args[1]);
+ }
+
+ [Fact]
+ public void WithPoetry_ThrowsOnNullBuilder()
+ {
+ IResourceBuilder builder = null!;
+
+ var exception = Assert.Throws(() =>
+ builder.WithPoetry());
+
+ Assert.Equal("builder", exception.ParamName);
+ }
+
+ [Fact]
+ public async Task WithPoetry_AddsWaitForCompletionRelationship()
+ {
+ using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
+ using var tempDir = new TempDirectory();
+
+ var scriptName = "main.py";
+
+ builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
+ .WithPoetry();
+
+ 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 installerResource = appModel.Resources.OfType().Single();
+
+ var waitAnnotations = pythonAppResource.Annotations.OfType();
+ var waitForCompletionAnnotation = Assert.Single(waitAnnotations);
+ Assert.Equal(installerResource, waitForCompletionAnnotation.Resource);
+ Assert.Equal(WaitType.WaitForCompletion, waitForCompletionAnnotation.WaitType);
+ }
+
///
/// Helper method to manually trigger BeforeStartEvent for tests.
/// This is needed because BeforeStartEvent is normally triggered during StartAsync(),