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(),