Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
7935c5d
Initial plan
Copilot Nov 4, 2025
d667fae
Add WithPip and rename WithUvEnvironment to WithUv
Copilot Nov 4, 2025
d56a491
Update WithUv to bootstrap Python and expose custom args
Copilot Nov 4, 2025
ccbf1df
Unify WithPip and WithUv to follow Node.js pattern
Copilot Nov 4, 2025
aac0897
Remove unused resource files (PythonPipInstallerResource, PythonUvEnv…
Copilot Nov 4, 2025
1577270
Add support for detecting pyproject.toml
Copilot Nov 4, 2025
b3c803c
Make pip support both pyproject.toml and requirements.txt
Copilot Nov 4, 2025
b4b83d0
Add venv creator resource for pip package manager
Copilot Nov 4, 2025
51f1894
Refactor Python package management based on feedback
Copilot Nov 4, 2025
ba1f423
Add createIfNotExists parameter to WithVirtualEnvironment
Copilot Nov 4, 2025
0b0ba61
Refactor venv creation to use BeforeStartEvent and be directory-based
Copilot Nov 4, 2025
e51f072
Simplify venv creation to use per-resource child resources
Copilot Nov 4, 2025
8fc5073
Make venv creation independent of package manager
Copilot Nov 4, 2025
9c8026e
WIP: Refactor wait dependencies to use BeforeStartEvent (needs fixing)
Copilot Nov 5, 2025
b9f1ec1
Implement feedback: Rename to SetupDependencies, only run in RunMode,…
Copilot Nov 5, 2025
3a56b22
Fix failing tests - all 92 tests now passing
Copilot Nov 5, 2025
cf15d22
Use TryCreateResourceBuilder in RemoveVenvCreator
Copilot Nov 5, 2025
3b02a9c
Rename AddPythonScript to AddPythonApp with OverloadResolutionPriority
Copilot Nov 5, 2025
1a96d8b
Fix AddPythonScript/AddPythonApp naming - remove obsolete, rename cor…
Copilot Nov 5, 2025
2dc56ce
Fix build.
eerhardt Nov 5, 2025
14c5d98
Remove AddPythonScript
eerhardt Nov 5, 2025
d44a5dc
Fix WithUv default args - remove incomplete --python flag
Copilot Nov 5, 2025
f475d27
Fixed tests
davidfowl Nov 5, 2025
58fc26b
Minor PR feedback
eerhardt Nov 5, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions playground/python/Python.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
builder.AddPythonModule("fastapi-app", "../module_only", "uvicorn")
.WithArgs("api:app", "--reload", "--host=0.0.0.0", "--port=8000")
.WithHttpEndpoint(targetPort: 8000)
.WithUvEnvironment();
.WithUv();

// Run the same app on another port using uvicorn directly
builder.AddPythonExecutable("fastapi-uvicorn-app", "../module_only", "uvicorn")
Expand All @@ -27,11 +27,11 @@
c.Args.Add("--port=8002");
})
.WithHttpEndpoint(targetPort: 8002)
.WithUvEnvironment();
.WithUv();

// Uvicorn app using the AddUvicornApp method
builder.AddUvicornApp("uvicorn-app", "../uvicorn_app", "app:app")
.WithUvEnvironment()
.WithUv()
.WithExternalHttpEndpoints();

#if !SKIP_DASHBOARD_REFERENCE
Expand Down
199 changes: 166 additions & 33 deletions src/Aspire.Hosting.Python/PythonAppResourceBuilderExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -496,6 +496,17 @@ private static IResourceBuilder<T> AddPythonAppCore<T>(
}
});

// Automatically add pip as the package manager if a requirements.txt file exists
// Only do this in run mode since the installer resource only runs in run mode
if (builder.ExecutionContext.IsRunMode)
{
var appDirectoryFullPath = Path.GetFullPath(appDirectory, builder.AppHostDirectory);
Comment thread
davidfowl marked this conversation as resolved.
if (File.Exists(Path.Combine(appDirectoryFullPath, "requirements.txt")))
{
resourceBuilder.WithPip();
}
}

return resourceBuilder;
}

Expand Down Expand Up @@ -1063,81 +1074,203 @@ public static IResourceBuilder<T> WithEntrypoint<T>(
return builder;
}

/// <summary>
/// Configures the Python resource to use pip as the package manager and optionally installs packages before the application starts.
/// </summary>
/// <typeparam name="T">The type of the Python application resource, must derive from <see cref="PythonAppResource"/>.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="install">When true (default), automatically installs packages before the application starts. When false, only sets the package manager annotation without creating an installer resource.</param>
/// <param name="installArgs">The command-line arguments passed to "pip install -r requirements.txt".</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for method chaining.</returns>
/// <remarks>
/// <para>
/// This method creates a child resource that runs <c>pip install -r requirements.txt</c> in the working directory of the Python application.
/// The Python application will wait for this resource to complete successfully before starting.
/// </para>
/// <para>
/// By default, if a requirements.txt file exists in the application directory, pip will install the dependencies.
/// Calling this method will replace any previously configured package manager (such as uv).
/// </para>
/// </remarks>
/// <example>
/// Add a Python app with automatic pip package installation:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var python = builder.AddPythonScript("api", "../python-api", "main.py")
/// .WithPip() // Automatically runs 'pip install -r requirements.txt' before starting the app
/// .WithHttpEndpoint(port: 5000);
///
/// builder.Build().Run();
/// </code>
/// </example>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
public static IResourceBuilder<T> WithPip<T>(this IResourceBuilder<T> builder, bool install = true, string[]? installArgs = null)
where T : PythonAppResource
{
ArgumentNullException.ThrowIfNull(builder);

// Get or create the virtual environment from the annotation
if (!builder.Resource.TryGetLastAnnotation<PythonEnvironmentAnnotation>(out var pythonEnv) ||
pythonEnv.VirtualEnvironment is null)
{
throw new InvalidOperationException("Cannot add pip installer: Python environment annotation with virtual environment not found.");
}

var virtualEnvironment = pythonEnv.VirtualEnvironment;

builder
.WithAnnotation(new PythonPackageManagerAnnotation(virtualEnvironment.GetExecutable("pip")), ResourceAnnotationMutationBehavior.Replace)
.WithAnnotation(new PythonInstallCommandAnnotation(["install", "-r", "requirements.txt", .. installArgs ?? []]), ResourceAnnotationMutationBehavior.Replace);

AddInstaller(builder, install);
return builder;
}

/// <summary>
/// Adds a UV environment setup task to ensure the virtual environment exists before running the Python application.
/// </summary>
/// <typeparam name="T">The type of the Python application resource, must derive from <see cref="PythonAppResource"/>.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <param name="install">When true (default), automatically runs uv sync before the application starts. When false, only sets the package manager annotation without creating an installer resource.</param>
/// <param name="args">Optional custom arguments to pass to the uv sync command. If not provided, defaults to ["sync", "--python"].</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for method chaining.</returns>
/// <remarks>
/// <para>
/// This method creates a child resource that runs <c>uv sync</c> in the working directory of the Python application.
/// This method creates a child resource that runs <c>uv sync --python</c> in the working directory of the Python application.
/// The Python application will wait for this resource to complete successfully before starting.
/// </para>
/// <para>
/// UV (https://github.com/astral-sh/uv) is a modern Python package manager written in Rust that can manage virtual environments
/// and dependencies with significantly faster performance than traditional tools. The <c>uv sync</c> command ensures that the virtual
/// environment exists and all dependencies specified in pyproject.toml are installed and synchronized.
/// and dependencies with significantly faster performance than traditional tools. The <c>uv sync --python</c> command ensures that the virtual
/// environment exists, Python is installed if needed, and all dependencies specified in pyproject.toml are installed and synchronized.
/// </para>
/// <para>
/// This method is idempotent - calling it multiple times on the same resource will not create duplicate UV environment resources.
/// If a UV environment resource already exists for the Python application, it will be reused.
/// Calling this method will replace any previously configured package manager (such as pip).
/// </para>
/// </remarks>
/// <example>
/// Add a Python app with automatic UV environment setup:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var python = builder.AddPythonApp("api", "../python-api", "main.py")
/// .WithUvEnvironment() // Automatically runs 'uv sync' before starting the app
/// var python = builder.AddPythonScript("api", "../python-api", "main.py")
/// .WithUv() // Automatically runs 'uv sync --python' before starting the app
/// .WithHttpEndpoint(port: 5000);
///
/// builder.Build().Run();
/// </code>
/// </example>
/// <example>
/// Add a Python app with custom UV arguments:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// var python = builder.AddPythonScript("api", "../python-api", "main.py")
/// .WithUv(args: ["sync", "--python", "3.12", "--no-dev"]) // Custom uv sync args
/// .WithHttpEndpoint(port: 5000);
///
/// builder.Build().Run();
/// </code>
/// </example>
/// <exception cref="ArgumentNullException">Thrown when <paramref name="builder"/> is null.</exception>
/// <exception cref="DistributedApplicationException">
/// Thrown when a resource with the UV environment name already exists but is not a <see cref="PythonUvEnvironmentResource"/>.
/// </exception>
public static IResourceBuilder<T> WithUvEnvironment<T>(this IResourceBuilder<T> builder)
public static IResourceBuilder<T> WithUv<T>(this IResourceBuilder<T> builder, bool install = true, string[]? args = null)
where T : PythonAppResource
{
ArgumentNullException.ThrowIfNull(builder);

var uvEnvironmentName = $"{builder.Resource.Name}-uv-environment";
// Default args include --python flag to bootstrap Python if needed
args ??= ["sync", "--python"];

builder
.WithAnnotation(new PythonPackageManagerAnnotation("uv"), ResourceAnnotationMutationBehavior.Replace)
.WithAnnotation(new PythonInstallCommandAnnotation(args), ResourceAnnotationMutationBehavior.Replace);

// Check if the UV environment resource already exists
var existingResource = builder.ApplicationBuilder.Resources
.FirstOrDefault(r => string.Equals(r.Name, uvEnvironmentName, StringComparison.OrdinalIgnoreCase));
AddInstaller(builder, install);

// Mark that we're using UV for the Python environment
builder.WithPythonEnvironment(env => env.Uv = true);

return builder;
}

IResourceBuilder<PythonUvEnvironmentResource> uvBuilder;
/// <summary>
/// Adds a UV environment setup task to ensure the virtual environment exists before running the Python application.
/// </summary>
/// <typeparam name="T">The type of the Python application resource, must derive from <see cref="PythonAppResource"/>.</typeparam>
/// <param name="builder">The resource builder.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for method chaining.</returns>
/// <remarks>
/// This method is obsolete. Use <see cref="WithUv{T}(IResourceBuilder{T}, bool, string[])"/> instead.
/// </remarks>
[Obsolete("Use WithUv instead.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static IResourceBuilder<T> WithUvEnvironment<T>(this IResourceBuilder<T> builder)
where T : PythonAppResource
{
return builder.WithUv(install: true, args: null);
}

if (existingResource is not null)
private static void AddInstaller<T>(IResourceBuilder<T> builder, bool install) where T : PythonAppResource
{
// Only install packages if in run mode
if (builder.ApplicationBuilder.ExecutionContext.IsRunMode)
{
// Resource already exists, return a builder for it
if (existingResource is not PythonUvEnvironmentResource uvEnvironmentResource)
// Check if the installer resource already exists
var installerName = $"{builder.Resource.Name}-installer";
builder.ApplicationBuilder.TryCreateResourceBuilder<PythonInstallerResource>(installerName, out var existingResource);

if (!install)
{
throw new DistributedApplicationException($"Cannot add UV environment resource with name '{uvEnvironmentName}' because a resource of type '{existingResource.GetType()}' with that name already exists.");
if (existingResource != null)
{
// Remove existing installer resource if install is false
builder.ApplicationBuilder.Resources.Remove(existingResource.Resource);
builder.Resource.Annotations.OfType<WaitAnnotation>()
.Where(w => w.Resource == existingResource.Resource)
.ToList()
.ForEach(w => builder.Resource.Annotations.Remove(w));
builder.Resource.Annotations.OfType<PythonPackageInstallerAnnotation>()
.ToList()
.ForEach(a => builder.Resource.Annotations.Remove(a));
}
return;
}

uvBuilder = builder.ApplicationBuilder.CreateResourceBuilder(uvEnvironmentResource);
}
else
{
// Resource doesn't exist, create it
var uvEnvironmentResource = new PythonUvEnvironmentResource(uvEnvironmentName, builder.Resource);
if (existingResource is not null)
{
// Installer already exists, it will be reconfigured via BeforeStartEvent
return;
}

uvBuilder = builder.ApplicationBuilder.AddResource(uvEnvironmentResource)
.WithArgs("sync")
.WithParentRelationship(builder)
var installer = new PythonInstallerResource(installerName, builder.Resource);
var installerBuilder = builder.ApplicationBuilder.AddResource(installer)
.WithParentRelationship(builder.Resource)
.ExcludeFromManifest();

builder.WaitForCompletion(uvBuilder)
.WithPythonEnvironment(env => env.Uv = true);
}
builder.ApplicationBuilder.Eventing.Subscribe<BeforeStartEvent>((_, _) =>
{
// Set the installer's working directory to match the resource's working directory
// and set the install command and args based on the resource's annotations
if (!builder.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager) ||
!builder.Resource.TryGetLastAnnotation<PythonInstallCommandAnnotation>(out var installCommand))
{
throw new InvalidOperationException("PythonPackageManagerAnnotation and PythonInstallCommandAnnotation are required when installing packages.");
}

return builder;
installerBuilder
.WithCommand(packageManager.ExecutableName)
.WithWorkingDirectory(builder.Resource.WorkingDirectory)
.WithArgs(installCommand.Args);

return Task.CompletedTask;
});
Comment on lines +1299 to +1317

Copilot AI Nov 5, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This event handler configures the installer on every BeforeStartEvent for any resource in the application, not just for the specific Python app resource. This could cause the installer to be reconfigured multiple times unnecessarily. Consider adding a filter to check if the event pertains to the relevant resource or use a mechanism to ensure this runs only once.

Copilot uses AI. Check for mistakes.

// Make the parent resource wait for the installer to complete
builder.WaitForCompletion(installerBuilder);

builder.WithAnnotation(new PythonPackageInstallerAnnotation(installer));
}
}

internal static IResourceBuilder<PythonAppResource> WithPythonEnvironment(this IResourceBuilder<PythonAppResource> builder, Action<PythonEnvironmentAnnotation> configure)
Expand Down
21 changes: 21 additions & 0 deletions src/Aspire.Hosting.Python/PythonInstallCommandAnnotation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Python;

/// <summary>
/// Represents the annotation for the Python package manager's install command.
/// </summary>
/// <param name="args">
/// The command line arguments for the Python package manager's install command.
/// This includes the command itself (i.e. "install", "sync").
/// </param>
internal sealed class PythonInstallCommandAnnotation(string[] args) : IResourceAnnotation
{
/// <summary>
/// Gets the command-line arguments supplied to the Python package manager.
/// </summary>
public string[] Args { get; } = args;
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
namespace Aspire.Hosting.Python;

/// <summary>
/// A resource that represents a UV environment setup task for a Python application.
/// A resource that represents a Python package installer task for a Python application.
/// </summary>
/// <param name="name">The name of the resource.</param>
/// <param name="parent">The parent Python application resource.</param>
internal sealed class PythonUvEnvironmentResource(string name, PythonAppResource parent)
: ExecutableResource(name, "uv", parent.WorkingDirectory)
internal sealed class PythonInstallerResource(string name, PythonAppResource parent)
: ExecutableResource(name, "python", parent.WorkingDirectory)
{
}
17 changes: 17 additions & 0 deletions src/Aspire.Hosting.Python/PythonPackageInstallerAnnotation.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Hosting.ApplicationModel;

namespace Aspire.Hosting.Python;

/// <summary>
/// Represents an annotation for a Python installer resource.
/// </summary>
internal sealed class PythonPackageInstallerAnnotation(ExecutableResource installerResource) : IResourceAnnotation
{
/// <summary>
/// The instance of the Installer resource used.
/// </summary>
public ExecutableResource Resource { get; } = installerResource;
}
18 changes: 18 additions & 0 deletions src/Aspire.Hosting.Python/PythonPackageManagerAnnotation.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Represents the annotation for the Python package manager used in a resource.
/// </summary>
/// <param name="executableName">The name of the executable used to run the package manager.</param>
internal sealed class PythonPackageManagerAnnotation(string executableName) : IResourceAnnotation
{
/// <summary>
/// Gets the executable used to run the Python package manager.
/// </summary>
public string ExecutableName { get; } = executableName;
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

#endif
var app = builder.AddUvicornApp("app", "./app", "main:app")
.WithUvEnvironment()
.WithUv()
.WithExternalHttpEndpoints()
#if UseRedisCache
.WithReference(cache)
Expand Down
Loading