|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
using System.ComponentModel;
using System.Runtime.CompilerServices;
#pragma warning disable ASPIREEXTENSION001
using Aspire.Hosting.ApplicationModel;
using Aspire.Hosting.Python;
namespace Aspire.Hosting;
/// <summary>
/// Provides extension methods for adding Python applications to an <see cref="IDistributedApplicationBuilder"/>.
/// </summary>
public static class PythonAppResourceBuilderExtensions
{
private const string DefaultVirtualEnvFolder = ".venv";
/// <summary>
/// Adds a python application to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python app files.</param>
/// <param name="scriptPath">The path to the script relative to the app directory to run.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This method is obsolete. Use one of the more specific methods instead:
/// </para>
/// <list type="bullet">
/// <item><description><see cref="AddPythonScript"/> - To run a Python script file</description></item>
/// <item><description><see cref="AddPythonModule"/> - To run a Python module via <c>python -m</c></description></item>
/// <item><description><see cref="AddPythonExecutable"/> - To run an executable from the virtual environment</description></item>
/// </list>
/// <para>
/// These new methods provide better clarity about how the Python application will be executed.
/// You can also use <see cref="WithEntrypoint"/> to change the entrypoint type after creation.
/// </para>
/// </remarks>
/// <example>
/// Replace with <see cref="AddPythonScript"/>:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddPythonScript("python-app", "../python-app", "main.py")
/// .WithArgs("arg1", "arg2");
///
/// builder.Build().Run();
/// </code>
/// </example>
[Obsolete("Use AddPythonScript, AddPythonModule, or AddPythonExecutable instead for more explicit control over how the Python application is executed.")]
[OverloadResolutionPriority(1)]
[EditorBrowsable(EditorBrowsableState.Never)]
public static IResourceBuilder<PythonAppResource> AddPythonApp(
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath)
=> AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, DefaultVirtualEnvFolder);
/// <summary>
/// Adds a Python script to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python script.</param>
/// <param name="scriptPath">The path to the script relative to the app directory to run.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This method executes a Python script directly using <c>python script.py</c>.
/// By default, the virtual environment folder is expected to be named <c>.venv</c> and located in the app directory.
/// Use <see cref="WithVirtualEnvironment(IResourceBuilder{PythonAppResource}, string)"/> to specify a different virtual environment path.
/// Use <c>WithArgs</c> to pass arguments to the script.
/// </para>
/// </remarks>
/// <example>
/// Add a FastAPI Python script to the application model:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddPythonScript("fastapi-app", "../api", "main.py")
/// .WithArgs("arg1", "arg2");
///
/// builder.Build().Run();
/// </code>
/// </example>
public static IResourceBuilder<PythonAppResource> AddPythonScript(
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string scriptPath)
=> AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, DefaultVirtualEnvFolder);
/// <summary>
/// Adds a Python module to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python application.</param>
/// <param name="moduleName">The name of the Python module to run (e.g., "flask", "uvicorn").</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This method runs a Python module using <c>python -m <module></c>.
/// By default, the virtual environment folder is expected to be named <c>.venv</c> and located in the app directory.
/// Use <see cref="WithVirtualEnvironment(IResourceBuilder{PythonAppResource}, string)"/> to specify a different virtual environment path.
/// Use <c>WithArgs</c> to pass arguments to the module.
/// </para>
/// </remarks>
/// <example>
/// Add a Flask module to the application model:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddPythonModule("flask-dev", "../flaskapp", "flask")
/// .WithArgs("run", "--debug", "--host=0.0.0.0");
///
/// builder.Build().Run();
/// </code>
/// </example>
public static IResourceBuilder<PythonAppResource> AddPythonModule(
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string moduleName)
=> AddPythonAppCore(builder, name, appDirectory, EntrypointType.Module, moduleName, DefaultVirtualEnvFolder);
/// <summary>
/// Adds a Python executable to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python application.</param>
/// <param name="executableName">The name of the executable in the virtual environment (e.g., "pytest", "uvicorn", "flask").</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This method runs an executable from the virtual environment's bin directory.
/// By default, the virtual environment folder is expected to be named <c>.venv</c> and located in the app directory.
/// Use <see cref="WithVirtualEnvironment(IResourceBuilder{PythonAppResource}, string)"/> to specify a different virtual environment path.
/// Use <c>WithArgs</c> to pass arguments to the executable.
/// </para>
/// </remarks>
/// <example>
/// Add a pytest executable to the application model:
/// <code lang="csharp">
/// var builder = DistributedApplication.CreateBuilder(args);
///
/// builder.AddPythonExecutable("pytest", "../api", "pytest")
/// .WithArgs("-q");
///
/// builder.Build().Run();
/// </code>
/// </example>
public static IResourceBuilder<PythonAppResource> AddPythonExecutable(
this IDistributedApplicationBuilder builder, [ResourceName] string name, string appDirectory, string executableName)
=> AddPythonAppCore(builder, name, appDirectory, EntrypointType.Executable, executableName, DefaultVirtualEnvFolder);
/// <summary>
/// Adds a python application with a virtual environment to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python app files.</param>
/// <param name="scriptPath">The path to the script relative to the app directory to run.</param>
/// <param name="scriptArgs">The arguments for the script.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This overload is obsolete. Use one of the more specific methods instead:
/// </para>
/// <list type="bullet">
/// <item><description><see cref="AddPythonScript"/> - To run a Python script file</description></item>
/// <item><description><see cref="AddPythonModule"/> - To run a Python module via <c>python -m</c></description></item>
/// <item><description><see cref="AddPythonExecutable"/> - To run an executable from the virtual environment</description></item>
/// </list>
/// <para>
/// Chain with <c>WithArgs</c> to pass arguments:
/// </para>
/// <example>
/// <code lang="csharp">
/// builder.AddPythonScript("name", "dir", "script.py")
/// .WithArgs("arg1", "arg2");
/// </code>
/// </example>
/// </remarks>
[Obsolete("Use AddPythonScript, AddPythonModule, or AddPythonExecutable and chain with .WithArgs(...) instead.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static IResourceBuilder<PythonAppResource> AddPythonApp(
this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath, params string[] scriptArgs)
{
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
ThrowIfNullOrContainsIsNullOrEmpty(scriptArgs);
return AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, DefaultVirtualEnvFolder)
.WithArgs(scriptArgs);
}
/// <summary>
/// Adds a python application with a virtual environment to the application model.
/// </summary>
/// <param name="builder">The <see cref="IDistributedApplicationBuilder"/> to add the resource to.</param>
/// <param name="name">The name of the resource.</param>
/// <param name="appDirectory">The path to the directory containing the python app files.</param>
/// <param name="scriptPath">The path to the script to run, relative to the app directory.</param>
/// <param name="virtualEnvironmentPath">Path to the virtual environment.</param>
/// <param name="scriptArgs">The arguments for the script.</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/>.</returns>
/// <remarks>
/// <para>
/// This overload is obsolete. Use one of the more specific methods instead:
/// </para>
/// <list type="bullet">
/// <item><description><see cref="AddPythonScript"/> - To run a Python script file</description></item>
/// <item><description><see cref="AddPythonModule"/> - To run a Python module via <c>python -m</c></description></item>
/// <item><description><see cref="AddPythonExecutable"/> - To run an executable from the virtual environment</description></item>
/// </list>
/// <para>
/// Chain with <see cref="WithVirtualEnvironment"/> and <c>WithArgs</c>:
/// </para>
/// <example>
/// <code lang="csharp">
/// builder.AddPythonScript("name", "dir", "script.py")
/// .WithVirtualEnvironment("myenv")
/// .WithArgs("arg1", "arg2");
/// </code>
/// </example>
/// </remarks>
[Obsolete("Use AddPythonScript, AddPythonModule, or AddPythonExecutable and chain with .WithVirtualEnvironment(...).WithArgs(...) instead.")]
[EditorBrowsable(EditorBrowsableState.Never)]
public static IResourceBuilder<PythonAppResource> AddPythonApp(
this IDistributedApplicationBuilder builder, string name, string appDirectory, string scriptPath,
string virtualEnvironmentPath, params string[] scriptArgs)
{
ThrowIfNullOrContainsIsNullOrEmpty(scriptArgs);
ArgumentException.ThrowIfNullOrEmpty(scriptPath);
return AddPythonAppCore(builder, name, appDirectory, EntrypointType.Script, scriptPath, virtualEnvironmentPath)
.WithArgs(scriptArgs);
}
private static IResourceBuilder<PythonAppResource> AddPythonAppCore(
IDistributedApplicationBuilder builder, string name, string appDirectory, EntrypointType entrypointType,
string entrypoint, string virtualEnvironmentPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(name);
ArgumentNullException.ThrowIfNull(appDirectory);
ArgumentException.ThrowIfNullOrEmpty(entrypoint);
ArgumentNullException.ThrowIfNull(virtualEnvironmentPath);
// python will be replaced with the resolved entrypoint based on the virtualEnvironmentPath
var resource = new PythonAppResource(name, "python", Path.GetFullPath(appDirectory, builder.AppHostDirectory));
var resourceBuilder = builder
.AddResource(resource)
// Order matters, we need to bootstrap the entrypoint before setting the entrypoint
.WithAnnotation(new PythonEntrypointAnnotation
{
Type = entrypointType,
Entrypoint = entrypoint
})
// This will resolve the correct python executable based on the virtual environment
.WithVirtualEnvironment(virtualEnvironmentPath)
// This will set up the the entrypoint based on the PythonEntrypointAnnotation
.WithEntrypoint(entrypointType, entrypoint);
resourceBuilder.WithIconName("CodePyRectangle");
resourceBuilder.WithOtlpExporter();
// Configure OpenTelemetry exporters using environment variables
// https://opentelemetry.io/docs/specs/otel/configuration/sdk-environment-variables/#exporter-selection
resourceBuilder.WithEnvironment(context =>
{
context.EnvironmentVariables["OTEL_TRACES_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_LOGS_EXPORTER"] = "otlp";
context.EnvironmentVariables["OTEL_METRICS_EXPORTER"] = "otlp";
// Make sure to attach the logging instrumentation setting, so we can capture logs.
// Without this you'll need to configure logging yourself. Which is kind of a pain.
context.EnvironmentVariables["OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED"] = "true";
// Set PYTHONUTF8=1 on Windows in run mode to enable UTF-8 mode
// See: https://docs.python.org/3/using/cmdline.html#envvar-PYTHONUTF8
if (OperatingSystem.IsWindows() && context.ExecutionContext.IsRunMode)
{
context.EnvironmentVariables["PYTHONUTF8"] = "1";
}
});
// Configure required environment variables for custom certificate trust when running as an executable
resourceBuilder.WithExecutableCertificateTrustCallback(ctx =>
{
if (ctx.Scope == CustomCertificateAuthoritiesScope.Override)
{
// See: https://docs.python-requests.org/en/latest/user/advanced/#ssl-cert-verification
ctx.CertificateBundleEnvironment.Add("REQUESTS_CA_BUNDLE");
}
// Override default opentelemetry-python certificate bundle path
// See: https://opentelemetry-python.readthedocs.io/en/latest/exporter/otlp/otlp.html#module-opentelemetry.exporter.otlp
ctx.CertificateBundleEnvironment.Add("OTEL_EXPORTER_OTLP_CERTIFICATE");
return Task.CompletedTask;
});
// VS Code debug support - only applicable for Script and Module types
if (entrypointType is EntrypointType.Script or EntrypointType.Module)
{
var programPath = entrypointType == EntrypointType.Script
? Path.GetFullPath(entrypoint, resource.WorkingDirectory)
: null; // For modules, we'll use the module name
resourceBuilder.WithVSCodeDebugSupport(
mode => new PythonLaunchConfiguration
{
ProgramPath = programPath,
Module = entrypointType == EntrypointType.Module ? entrypoint : null,
Mode = mode
},
"ms-python.python",
static ctx =>
{
// Remove entrypoint-specific arguments that VS Code will handle.
// We need to verify the annotation to ensure we remove the correct args.
if (!ctx.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var annotation))
{
return;
}
// For Module type: remove "-m" and module name (2 args)
if (annotation.Type == EntrypointType.Module)
{
if (ctx.Args is [string arg0, string arg1, ..] &&
arg0 == "-m" &&
arg1 == annotation.Entrypoint)
{
ctx.Args.RemoveAt(0); // Remove "-m"
ctx.Args.RemoveAt(0); // Remove module name
}
}
// For Script type: remove script path (1 arg)
else if (annotation.Type == EntrypointType.Script)
{
if (ctx.Args is [string arg0, ..] &&
arg0 == annotation.Entrypoint)
{
ctx.Args.RemoveAt(0); // Remove script path
}
}
});
}
resourceBuilder.PublishAsDockerFile(c =>
{
// Only generate a Dockerfile if one doesn't already exist in the app directory
if (File.Exists(Path.Combine(resource.WorkingDirectory, "Dockerfile")))
{
return;
}
c.WithDockerfileBuilder(resource.WorkingDirectory,
context =>
{
if (!c.Resource.TryGetLastAnnotation<PythonEnvironmentAnnotation>(out var pythonEnvironmentAnnotation) ||
!pythonEnvironmentAnnotation.Uv)
{
// Use the default Dockerfile if not using UV
return;
}
if (!context.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var entrypointAnnotation))
{
// No entrypoint annotation found, cannot generate Dockerfile
return;
}
var pythonVersion = pythonEnvironmentAnnotation.Version ?? PythonVersionDetector.DetectVersion(appDirectory, pythonEnvironmentAnnotation.VirtualEnvironment!);
if (pythonVersion is null)
{
// Could not detect Python version, skip Dockerfile generation
return;
}
var entrypointType = entrypointAnnotation.Type;
var entrypoint = entrypointAnnotation.Entrypoint;
// Determine entry command for Dockerfile
string[] entryCommand = entrypointType switch
{
EntrypointType.Script => ["python", entrypoint],
EntrypointType.Module => ["python", "-m", entrypoint],
EntrypointType.Executable => [entrypoint],
_ => throw new InvalidOperationException($"Unsupported entrypoint type: {entrypointType}")
};
var builderStage = context.Builder
.From($"ghcr.io/astral-sh/uv:python{pythonVersion}-bookworm-slim", "builder")
.EmptyLine()
.Comment("Enable bytecode compilation and copy mode for the virtual environment")
.Env("UV_COMPILE_BYTECODE", "1")
.Env("UV_LINK_MODE", "copy")
.EmptyLine()
.WorkDir("/app")
.EmptyLine()
.Comment("Install dependencies first for better layer caching")
.Comment("Uses BuildKit cache mounts to speed up repeated builds")
.RunWithMounts(
"uv sync --locked --no-install-project --no-dev",
"type=cache,target=/root/.cache/uv",
"type=bind,source=uv.lock,target=uv.lock",
"type=bind,source=pyproject.toml,target=pyproject.toml")
.EmptyLine()
.Comment("Copy the rest of the application source and install the project")
.Copy(".", "/app")
.RunWithMounts(
"uv sync --locked --no-dev",
"type=cache,target=/root/.cache/uv");
var runtimeBuilder = context.Builder
.From($"python:{pythonVersion}-slim-bookworm", "app")
.EmptyLine()
.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;
}
});
});
return resourceBuilder;
}
private static void ThrowIfNullOrContainsIsNullOrEmpty(string[] scriptArgs)
{
ArgumentNullException.ThrowIfNull(scriptArgs);
foreach (var scriptArg in scriptArgs)
{
if (string.IsNullOrEmpty(scriptArg))
{
var values = string.Join(", ", scriptArgs);
if (scriptArg is null)
{
throw new ArgumentNullException(nameof(scriptArgs), $"Array params contains null item: [{values}]");
}
throw new ArgumentException($"Array params contains empty item: [{values}]", nameof(scriptArgs));
}
}
}
/// <summary>
/// Configures a custom virtual environment path for the Python application.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="virtualEnvironmentPath">
/// The path to the virtual environment. Can be absolute or relative to the app directory.
/// When relative, it is resolved from the working directory of the Python application.
/// Common values include ".venv", "venv", or "myenv".
/// </param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for method chaining.</returns>
/// <remarks>
/// <para>
/// This method updates the Python executable path to use the specified virtual environment.
/// The virtual environment must already exist and be properly initialized before the application runs.
/// </para>
/// <para>
/// Virtual environments allow Python applications to have isolated dependencies separate from
/// the system Python installation. This is the recommended approach for Python applications.
/// </para>
/// </remarks>
/// <example>
/// Configure a Python app to use a custom virtual environment:
/// <code lang="csharp">
/// var python = builder.AddPythonApp("api", "../python-api", "main.py")
/// .WithVirtualEnvironment("myenv");
/// </code>
/// </example>
public static IResourceBuilder<PythonAppResource> WithVirtualEnvironment(
this IResourceBuilder<PythonAppResource> builder, string virtualEnvironmentPath)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(virtualEnvironmentPath);
var virtualEnvironment = new VirtualEnvironment(Path.IsPathRooted(virtualEnvironmentPath)
? virtualEnvironmentPath
: Path.GetFullPath(virtualEnvironmentPath, builder.Resource.WorkingDirectory));
// Get the entrypoint annotation to determine how to update the command
if (!builder.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var entrypointAnnotation))
{
throw new InvalidOperationException("Cannot update virtual environment: Python entrypoint annotation not found.");
}
// Update the command based on entrypoint type
string command = entrypointAnnotation.Type switch
{
EntrypointType.Executable => virtualEnvironment.GetExecutable(entrypointAnnotation.Entrypoint),
EntrypointType.Script or EntrypointType.Module => virtualEnvironment.GetExecutable("python"),
_ => throw new InvalidOperationException($"Unsupported entrypoint type: {entrypointAnnotation.Type}")
};
builder.WithCommand(command);
builder.WithPythonEnvironment(env =>
{
env.VirtualEnvironment = virtualEnvironment;
});
return builder;
}
/// <summary>
/// Configures the entrypoint for the Python application.
/// </summary>
/// <param name="builder">The resource builder.</param>
/// <param name="entrypointType">The type of entrypoint (Script, Module, or Executable).</param>
/// <param name="entrypoint">The entrypoint value (script path, module name, or executable name).</param>
/// <returns>A reference to the <see cref="IResourceBuilder{T}"/> for method chaining.</returns>
/// <remarks>
/// <para>
/// This method allows you to change the entrypoint configuration of a Python application after it has been created.
/// The command and arguments will be updated based on the specified entrypoint type:
/// </para>
/// <list type="bullet">
/// <item><description><b>Script</b>: Runs as <c>python <scriptPath></c></description></item>
/// <item><description><b>Module</b>: Runs as <c>python -m <moduleName></c></description></item>
/// <item><description><b>Executable</b>: Runs the executable directly from the virtual environment</description></item>
/// </list>
/// <para>
/// <b>Important:</b> This method resets all command-line arguments. If you need to add arguments after changing
/// the entrypoint, call <c>WithArgs</c> after this method.
/// </para>
/// </remarks>
/// <example>
/// Change a Python app from running a script to running a module:
/// <code lang="csharp">
/// var python = builder.AddPythonScript("api", "../python-api", "main.py")
/// .WithEntrypoint(EntrypointType.Module, "uvicorn")
/// .WithArgs("main:app", "--reload");
/// </code>
/// </example>
public static IResourceBuilder<PythonAppResource> WithEntrypoint(
this IResourceBuilder<PythonAppResource> builder, EntrypointType entrypointType, string entrypoint)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentException.ThrowIfNullOrEmpty(entrypoint);
// 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 set entrypoint: Python environment annotation with virtual environment not found.");
}
var virtualEnvironment = pythonEnv.VirtualEnvironment;
// Determine the new command based on entrypoint type
var command = entrypointType switch
{
EntrypointType.Executable => virtualEnvironment.GetExecutable(entrypoint),
EntrypointType.Script or EntrypointType.Module => virtualEnvironment.GetExecutable("python"),
_ => throw new ArgumentOutOfRangeException(nameof(entrypointType), entrypointType, "Invalid entrypoint type.")
};
// Update the command inline
builder.WithCommand(command);
builder.WithAnnotation(new PythonEntrypointAnnotation
{
Type = entrypointType,
Entrypoint = entrypoint
},
ResourceAnnotationMutationBehavior.Replace);
builder.WithArgs(static context =>
{
if (!context.Resource.TryGetLastAnnotation<PythonEntrypointAnnotation>(out var existingAnnotation))
{
return;
}
// Clear existing args since we're replacing the entrypoint
context.Args.Clear();
var entrypointType = existingAnnotation.Type;
var entrypoint = existingAnnotation.Entrypoint;
// Add entrypoint-specific arguments
switch (entrypointType)
{
case EntrypointType.Module:
context.Args.Add("-m");
context.Args.Add(entrypoint);
break;
case EntrypointType.Script:
context.Args.Add(entrypoint);
break;
case EntrypointType.Executable:
// Executable runs directly, no additional args needed for entrypoint
break;
}
});
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>
/// <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.
/// 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.
/// </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.
/// </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
/// .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)
where T : PythonAppResource
{
ArgumentNullException.ThrowIfNull(builder);
var uvEnvironmentName = $"{builder.Resource.Name}-uv-environment";
// Check if the UV environment resource already exists
var existingResource = builder.ApplicationBuilder.Resources
.FirstOrDefault(r => string.Equals(r.Name, uvEnvironmentName, StringComparison.OrdinalIgnoreCase));
IResourceBuilder<PythonUvEnvironmentResource> uvBuilder;
if (existingResource is not null)
{
// Resource already exists, return a builder for it
if (existingResource is not PythonUvEnvironmentResource uvEnvironmentResource)
{
throw new DistributedApplicationException($"Cannot add UV environment resource with name '{uvEnvironmentName}' because a resource of type '{existingResource.GetType()}' with that name already exists.");
}
uvBuilder = builder.ApplicationBuilder.CreateResourceBuilder(uvEnvironmentResource);
}
else
{
// Resource doesn't exist, create it
var uvEnvironmentResource = new PythonUvEnvironmentResource(uvEnvironmentName, builder.Resource);
uvBuilder = builder.ApplicationBuilder.AddResource(uvEnvironmentResource)
.WithArgs("sync")
.WithParentRelationship(builder)
.ExcludeFromManifest();
builder.WaitForCompletion(uvBuilder)
.WithPythonEnvironment(env => env.Uv = true);
}
return builder;
}
internal static IResourceBuilder<PythonAppResource> WithPythonEnvironment(this IResourceBuilder<PythonAppResource> builder, Action<PythonEnvironmentAnnotation> configure)
{
ArgumentNullException.ThrowIfNull(builder);
ArgumentNullException.ThrowIfNull(configure);
if (!builder.Resource.TryGetLastAnnotation<PythonEnvironmentAnnotation>(out var existing))
{
existing = new PythonEnvironmentAnnotation();
builder.WithAnnotation(existing);
}
configure(existing);
return builder;
}
}
|