From 1e9984cfc0e0dc76f954fd9980a9342d3a7f0d7a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 06:56:12 +0000 Subject: [PATCH 1/9] Initial plan From d5695d5513a2cdad8683a55840e060a1f135fb71 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 07:10:46 +0000 Subject: [PATCH 2/9] Add Poetry package manager support with tests Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../PoetryEnvironmentAnnotation.cs | 18 + .../PoetryInstallationManager.cs | 46 +++ .../PythonAppResourceBuilderExtensions.cs | 309 +++++++++++++++++- .../AddPythonAppTests.cs | 194 +++++++++++ 4 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.cs create mode 100644 src/Aspire.Hosting.Python/PoetryInstallationManager.cs diff --git a/src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.cs b/src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.cs new file mode 100644 index 00000000000..e689c62952b --- /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((string key, string value)[] environmentVariables) : IResourceAnnotation +{ + /// + /// Gets the environment variables to be set for Poetry operations. + /// + public (string key, string value)[] 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..1234f109ad8 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -478,14 +478,20 @@ private static IResourceBuilder AddPythonAppCore( var entrypointType = entrypointAnnotation.Type; var entrypoint = entrypointAnnotation.Entrypoint; - // Check if using UV by looking at the package manager annotation + // Check which package manager is being used var isUsingUv = context.Resource.TryGetLastAnnotation(out var pkgMgr) && pkgMgr.ExecutableName == "uv"; + var isUsingPoetry = context.Resource.TryGetLastAnnotation(out pkgMgr) && + pkgMgr.ExecutableName == "poetry"; if (isUsingUv) { GenerateUvDockerfile(context, resource, pythonVersion, entrypointType, entrypoint); } + else if (isUsingPoetry) + { + GeneratePoetryDockerfile(context, resource, pythonVersion, entrypointType, entrypoint); + } else { GenerateFallbackDockerfile(context, resource, pythonVersion, entrypointType, entrypoint); @@ -646,6 +652,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) { @@ -1276,6 +1387,118 @@ 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 override defaults like POETRY_VIRTUALENVS_IN_PROJECT. + /// 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. + /// By default, Aspire configures Poetry to create the virtual environment in the project directory (.venv) by setting + /// POETRY_VIRTUALENVS_IN_PROJECT=true, unless an explicit virtual environment path is configured via + /// or overridden via the parameter. + /// + /// + /// When using to specify an explicit path, Aspire will set POETRY_VIRTUALENVS_PATH + /// to direct Poetry to use that location. + /// + /// + /// 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(); + /// + /// + /// + /// Disable Poetry's in-project virtual environment: + /// + /// var builder = DistributedApplication.CreateBuilder(args); + /// + /// var python = builder.AddPythonApp("api", "../python-api", "main.py") + /// .WithPoetry(env: [("POETRY_VIRTUALENVS_IN_PROJECT", "false")]) + /// .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); + + // Register Poetry validation service + builder.ApplicationBuilder.Services.TryAddSingleton(); + + // 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) + { + builder.WithAnnotation(new PoetryEnvironmentAnnotation(env), 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 +1572,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 +1611,60 @@ 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 => + { + // Determine default Poetry environment variables based on virtual environment configuration + if (builder.Resource.TryGetLastAnnotation(out var pythonEnv) && + pythonEnv.VirtualEnvironment != null) + { + var venvPath = pythonEnv.VirtualEnvironment.VirtualEnvironmentPath; + var isAbsolutePath = Path.IsPathRooted(venvPath); + var resolvedPath = isAbsolutePath + ? venvPath + : Path.GetFullPath(venvPath, builder.Resource.WorkingDirectory); + + // Check if the venv path is within the project directory + var projectVenvPath = Path.GetFullPath(".venv", builder.Resource.WorkingDirectory); + var isInProject = string.Equals( + resolvedPath, + projectVenvPath, + OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); + + if (isInProject) + { + // Use in-project venv + ctx.EnvironmentVariables["POETRY_VIRTUALENVS_IN_PROJECT"] = "true"; + } + else + { + // Use external venv path + var venvParentPath = Path.GetDirectoryName(resolvedPath); + if (!string.IsNullOrEmpty(venvParentPath)) + { + ctx.EnvironmentVariables["POETRY_VIRTUALENVS_PATH"] = venvParentPath; + } + } + } + else + { + // Default to in-project venv if no explicit path configured + ctx.EnvironmentVariables["POETRY_VIRTUALENVS_IN_PROJECT"] = "true"; + } + + // Apply user-specified environment variable overrides + if (builder.Resource.TryGetLastAnnotation(out var poetryEnv)) + { + foreach (var (key, value) in poetryEnv.EnvironmentVariables) + { + ctx.EnvironmentVariables[key] = value; + } + } + }); + } + return Task.CompletedTask; }); @@ -1506,13 +1791,13 @@ 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"; + // Check if we're using uv or poetry (which handle venv creation themselves) + var isUsingPackageManagerWithVenv = builder.Resource.TryGetLastAnnotation(out var pkgMgr) && + (pkgMgr.ExecutableName == "uv" || pkgMgr.ExecutableName == "poetry"); - if (isUsingUv) + if (isUsingPackageManagerWithVenv) { - // UV handles venv creation, we don't need to create it + // UV and Poetry handle venv creation, we don't need to create it return false; } diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 8a1fea29671..851c0eb24e4 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.Length); + Assert.Contains(poetryEnv.EnvironmentVariables, e => e.key == "POETRY_VIRTUALENVS_IN_PROJECT" && e.value == "false"); + Assert.Contains(poetryEnv.EnvironmentVariables, e => e.key == "POETRY_HTTP_TIMEOUT" && e.value == "60"); + } + + [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(), From 4fc46a70eb24ab5e788ea8a7436d6596804e6b19 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 07:16:12 +0000 Subject: [PATCH 3/9] Update public API surface for WithPoetry method Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- src/Aspire.Hosting.Python/api/Aspire.Hosting.Python.cs | 2 ++ 1 file changed, 2 insertions(+) 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.")] From bdcf493ebe56efa7f1e97ce9efea97581eeeb14c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 07:23:07 +0000 Subject: [PATCH 4/9] Address code review feedback - optimize package manager checks Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../PythonAppResourceBuilderExtensions.cs | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 1234f109ad8..5a33d1fe8e4 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -479,16 +479,17 @@ private static IResourceBuilder AddPythonAppCore( var entrypoint = entrypointAnnotation.Entrypoint; // Check which package manager is being used - var isUsingUv = context.Resource.TryGetLastAnnotation(out var pkgMgr) && - pkgMgr.ExecutableName == "uv"; - var isUsingPoetry = context.Resource.TryGetLastAnnotation(out pkgMgr) && - pkgMgr.ExecutableName == "poetry"; + 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 (isUsingPoetry) + else if (packageManagerName == "poetry") { GeneratePoetryDockerfile(context, resource, pythonVersion, entrypointType, entrypoint); } @@ -1621,8 +1622,7 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w pythonEnv.VirtualEnvironment != null) { var venvPath = pythonEnv.VirtualEnvironment.VirtualEnvironmentPath; - var isAbsolutePath = Path.IsPathRooted(venvPath); - var resolvedPath = isAbsolutePath + var resolvedPath = Path.IsPathRooted(venvPath) ? venvPath : Path.GetFullPath(venvPath, builder.Resource.WorkingDirectory); @@ -1791,14 +1791,14 @@ private static void SetupDependencies(IDistributedApplicationBuilder builder, Py private static bool ShouldCreateVenv(IResourceBuilder builder) where T : PythonAppResource { - // Check if we're using uv or poetry (which handle venv creation themselves) - var isUsingPackageManagerWithVenv = builder.Resource.TryGetLastAnnotation(out var pkgMgr) && - (pkgMgr.ExecutableName == "uv" || pkgMgr.ExecutableName == "poetry"); - - if (isUsingPackageManagerWithVenv) + // Check if we're using a package manager that handles venv creation itself (uv, poetry) + if (builder.Resource.TryGetLastAnnotation(out var pkgMgr)) { - // UV and Poetry handle 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 From 28782b4b6edee13c4646efd6ffc605e4f6ab9eae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 21 Nov 2025 08:01:10 +0000 Subject: [PATCH 5/9] Use dictionary instead of array for PoetryEnvironmentAnnotation Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../PoetryEnvironmentAnnotation.cs | 4 ++-- .../PythonAppResourceBuilderExtensions.cs | 11 ++++++++--- .../Aspire.Hosting.Python.Tests/AddPythonAppTests.cs | 6 +++--- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.cs b/src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.cs index e689c62952b..b27a478b95b 100644 --- a/src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.cs +++ b/src/Aspire.Hosting.Python/PoetryEnvironmentAnnotation.cs @@ -9,10 +9,10 @@ namespace Aspire.Hosting.Python; /// Represents environment variables to be set for Poetry operations. /// /// The environment variables to set for Poetry. -internal sealed class PoetryEnvironmentAnnotation((string key, string value)[] environmentVariables) : IResourceAnnotation +internal sealed class PoetryEnvironmentAnnotation(IReadOnlyDictionary environmentVariables) : IResourceAnnotation { /// /// Gets the environment variables to be set for Poetry operations. /// - public (string key, string value)[] EnvironmentVariables { get; } = environmentVariables; + public IReadOnlyDictionary EnvironmentVariables { get; } = environmentVariables; } diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 5a33d1fe8e4..35ff2508ea6 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -1485,7 +1485,12 @@ public static IResourceBuilder WithPoetry( // Store Poetry environment variables in an annotation for later use if (env != null && env.Length > 0) { - builder.WithAnnotation(new PoetryEnvironmentAnnotation(env), ResourceAnnotationMutationBehavior.Replace); + var envDict = new Dictionary(env.Length); + foreach (var (key, value) in env) + { + envDict[key] = value; + } + builder.WithAnnotation(new PoetryEnvironmentAnnotation(envDict), ResourceAnnotationMutationBehavior.Replace); } builder @@ -1657,9 +1662,9 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w // Apply user-specified environment variable overrides if (builder.Resource.TryGetLastAnnotation(out var poetryEnv)) { - foreach (var (key, value) in poetryEnv.EnvironmentVariables) + foreach (var kvp in poetryEnv.EnvironmentVariables) { - ctx.EnvironmentVariables[key] = value; + ctx.EnvironmentVariables[kvp.Key] = kvp.Value; } } }); diff --git a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs index 851c0eb24e4..b362cd4c9c8 100644 --- a/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs +++ b/tests/Aspire.Hosting.Python.Tests/AddPythonAppTests.cs @@ -2356,9 +2356,9 @@ public void WithPoetry_WithEnvironmentVariables_StoresAnnotation() // Verify the Poetry environment annotation exists with the custom environment variables Assert.True(pythonApp.Resource.TryGetLastAnnotation(out var poetryEnv)); - Assert.Equal(2, poetryEnv.EnvironmentVariables.Length); - Assert.Contains(poetryEnv.EnvironmentVariables, e => e.key == "POETRY_VIRTUALENVS_IN_PROJECT" && e.value == "false"); - Assert.Contains(poetryEnv.EnvironmentVariables, e => e.key == "POETRY_HTTP_TIMEOUT" && e.value == "60"); + Assert.Equal(2, poetryEnv.EnvironmentVariables.Count); + Assert.Equal("false", poetryEnv.EnvironmentVariables["POETRY_VIRTUALENVS_IN_PROJECT"]); + Assert.Equal("60", poetryEnv.EnvironmentVariables["POETRY_HTTP_TIMEOUT"]); } [Fact] From 0e6d1009b9b800ec4da88e236362ddf719f23881 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 17:01:45 +0000 Subject: [PATCH 6/9] Check IsReadOnly before adding installation managers to service collection Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../PythonAppResourceBuilderExtensions.cs | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 35ff2508ea6..b3523073971 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -366,7 +366,10 @@ 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(); + } // 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; @@ -1371,7 +1374,10 @@ public static IResourceBuilder WithUv(this IResourceBuilder builder, bo ArgumentNullException.ThrowIfNull(builder); // Register UV validation service - builder.ApplicationBuilder.Services.TryAddSingleton(); + if (!builder.ApplicationBuilder.Services.IsReadOnly) + { + builder.ApplicationBuilder.Services.TryAddSingleton(); + } // Default args: sync only (uv will auto-detect Python and dependencies from pyproject.toml) args ??= ["sync"]; @@ -1463,7 +1469,10 @@ public static IResourceBuilder WithPoetry( ArgumentNullException.ThrowIfNull(builder); // Register Poetry validation service - builder.ApplicationBuilder.Services.TryAddSingleton(); + if (!builder.ApplicationBuilder.Services.IsReadOnly) + { + builder.ApplicationBuilder.Services.TryAddSingleton(); + } // Ensure virtual environment exists - create default .venv if not configured if (!builder.Resource.TryGetLastAnnotation(out var pythonEnv) || From f98188388221980035413a29d9cd32e966451893 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 22 Nov 2025 18:29:59 +0000 Subject: [PATCH 7/9] Don't set POETRY_VIRTUALENVS_IN_PROJECT or POETRY_VIRTUALENVS_PATH by default Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../PythonAppResourceBuilderExtensions.cs | 60 ++++--------------- 1 file changed, 10 insertions(+), 50 deletions(-) diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index b3523073971..a43a7aba9ae 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -674,8 +674,7 @@ private static void GeneratePoetryDockerfile(DockerfileBuilderCallbackContext co .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") + .Comment("Configure Poetry") .Env("POETRY_NO_INTERACTION", "1") .EmptyLine() .WorkDir("/app") @@ -1401,7 +1400,7 @@ public static IResourceBuilder WithUv(this IResourceBuilder builder, bo /// 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 override defaults like POETRY_VIRTUALENVS_IN_PROJECT. + /// 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. /// /// @@ -1410,13 +1409,9 @@ public static IResourceBuilder WithUv(this IResourceBuilder builder, bo /// /// /// Poetry (https://python-poetry.org/) is a Python package and dependency manager that handles virtual environment creation. - /// By default, Aspire configures Poetry to create the virtual environment in the project directory (.venv) by setting - /// POETRY_VIRTUALENVS_IN_PROJECT=true, unless an explicit virtual environment path is configured via - /// or overridden via the parameter. - /// - /// - /// When using to specify an explicit path, Aspire will set POETRY_VIRTUALENVS_PATH - /// to direct Poetry to use that location. + /// 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). @@ -1447,12 +1442,12 @@ public static IResourceBuilder WithUv(this IResourceBuilder builder, bo /// /// /// - /// Disable Poetry's in-project virtual environment: + /// 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", "false")]) + /// .WithPoetry(env: [("POETRY_VIRTUALENVS_IN_PROJECT", "true")]) /// .WithHttpEndpoint(port: 5000); /// /// builder.Build().Run(); @@ -1631,44 +1626,9 @@ private static void AddInstaller(IResourceBuilder builder, bool install) w { installerBuilder.WithEnvironment(ctx => { - // Determine default Poetry environment variables based on virtual environment configuration - if (builder.Resource.TryGetLastAnnotation(out var pythonEnv) && - pythonEnv.VirtualEnvironment != null) - { - var venvPath = pythonEnv.VirtualEnvironment.VirtualEnvironmentPath; - var resolvedPath = Path.IsPathRooted(venvPath) - ? venvPath - : Path.GetFullPath(venvPath, builder.Resource.WorkingDirectory); - - // Check if the venv path is within the project directory - var projectVenvPath = Path.GetFullPath(".venv", builder.Resource.WorkingDirectory); - var isInProject = string.Equals( - resolvedPath, - projectVenvPath, - OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); - - if (isInProject) - { - // Use in-project venv - ctx.EnvironmentVariables["POETRY_VIRTUALENVS_IN_PROJECT"] = "true"; - } - else - { - // Use external venv path - var venvParentPath = Path.GetDirectoryName(resolvedPath); - if (!string.IsNullOrEmpty(venvParentPath)) - { - ctx.EnvironmentVariables["POETRY_VIRTUALENVS_PATH"] = venvParentPath; - } - } - } - else - { - // Default to in-project venv if no explicit path configured - ctx.EnvironmentVariables["POETRY_VIRTUALENVS_IN_PROJECT"] = "true"; - } - - // Apply user-specified environment variable overrides + // 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) From fd7a110218abcd2c3918c3840cc6d89a672fab5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 04:19:36 +0000 Subject: [PATCH 8/9] Set POETRY_VIRTUALENVS_IN_PROJECT in container to create venv locally Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../PythonAppResourceBuilderExtensions.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index a43a7aba9ae..25a4aada169 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -674,7 +674,8 @@ private static void GeneratePoetryDockerfile(DockerfileBuilderCallbackContext co .Comment("Install Poetry") .Run("pip install --no-cache-dir poetry") .EmptyLine() - .Comment("Configure Poetry") + .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") From e648bc641079cd5d191563e78676d69f4b0bc12e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 23 Nov 2025 06:37:37 +0000 Subject: [PATCH 9/9] Register all installation managers upfront in AddPythonAppCore Co-authored-by: davidfowl <95136+davidfowl@users.noreply.github.com> --- .../PythonAppResourceBuilderExtensions.cs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs index 25a4aada169..c87138cb2fa 100644 --- a/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs +++ b/src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs @@ -369,6 +369,8 @@ private static IResourceBuilder AddPythonAppCore( 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 @@ -1373,12 +1375,6 @@ public static IResourceBuilder WithUv(this IResourceBuilder builder, bo { ArgumentNullException.ThrowIfNull(builder); - // Register UV validation service - if (!builder.ApplicationBuilder.Services.IsReadOnly) - { - builder.ApplicationBuilder.Services.TryAddSingleton(); - } - // Default args: sync only (uv will auto-detect Python and dependencies from pyproject.toml) args ??= ["sync"]; @@ -1464,12 +1460,6 @@ public static IResourceBuilder WithPoetry( { ArgumentNullException.ThrowIfNull(builder); - // Register Poetry validation service - if (!builder.ApplicationBuilder.Services.IsReadOnly) - { - builder.ApplicationBuilder.Services.TryAddSingleton(); - } - // Ensure virtual environment exists - create default .venv if not configured if (!builder.Resource.TryGetLastAnnotation(out var pythonEnv) || pythonEnv.VirtualEnvironment is null)