|
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
#pragma warning disable CS0612
#pragma warning disable CS0618 // Type or member is obsolete
#pragma warning disable ASPIREDOCKERFILEBUILDER001 // Type is for evaluation purposes only
using Microsoft.Extensions.DependencyInjection;
using Aspire.Hosting.Utils;
using Aspire.Hosting.Tests.Utils;
using System.Diagnostics;
using Aspire.TestUtilities;
using Aspire.Hosting.ApplicationModel;
using System.Text.Json;
using Aspire.Hosting.Dcp.Model;
using Aspire.Hosting.Eventing;
namespace Aspire.Hosting.Python.Tests;
public class AddPythonAppTests(ITestOutputHelper outputHelper)
{
[Fact]
[RequiresTools(["python"])]
public async Task AddPythonAppProducesDockerfileResourceInManifest()
{
var (projectDirectory, pythonExecutable, scriptName) = CreateTempPythonProject(outputHelper);
var manifestPath = Path.Combine(projectDirectory, "aspire-manifest.json");
using var builder = TestDistributedApplicationBuilder.Create(options =>
{
options.ProjectDirectory = Path.GetFullPath(projectDirectory);
options.Args = ["--publisher", "manifest", "--output-path", manifestPath];
}, outputHelper);
var pyproj = builder.AddPythonApp("pyproj", projectDirectory, scriptName);
var manifest = await ManifestUtils.GetManifest(pyproj.Resource, manifestDirectory: projectDirectory);
var expectedManifest = $$"""
{
"type": "container.v1",
"build": {
"context": ".",
"dockerfile": "Dockerfile"
},
"env": {
"OTEL_TRACES_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "true"
}
}
""";
Assert.Equal(expectedManifest, manifest.ToString(), ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true);
// If we don't throw, clean up the directories.
Directory.Delete(projectDirectory, true);
}
[Fact]
[RequiresTools(["python"])]
public async Task AddInstrumentedPythonProjectProducesDockerfileResourceInManifest()
{
var (projectDirectory, pythonExecutable, scriptName) = CreateTempPythonProject(outputHelper, instrument: true);
var manifestPath = Path.Combine(projectDirectory, "aspire-manifest.json");
using var builder = TestDistributedApplicationBuilder.Create(options =>
{
options.ProjectDirectory = Path.GetFullPath(projectDirectory);
options.Args = ["--publisher", "manifest", "--output-path", manifestPath];
}, outputHelper);
var pyproj = builder.AddPythonApp("pyproj", projectDirectory, scriptName);
var manifest = await ManifestUtils.GetManifest(pyproj.Resource, manifestDirectory: projectDirectory);
var expectedManifest = $$"""
{
"type": "container.v1",
"build": {
"context": ".",
"dockerfile": "Dockerfile"
},
"env": {
"OTEL_TRACES_EXPORTER": "otlp",
"OTEL_LOGS_EXPORTER": "otlp",
"OTEL_METRICS_EXPORTER": "otlp",
"OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED": "true"
}
}
""";
Assert.Equal(expectedManifest, manifest.ToString(), ignoreLineEndingDifferences: true, ignoreWhiteSpaceDifferences: true);
// If we don't throw, clean up the directories.
Directory.Delete(projectDirectory, true);
}
[Fact]
[RequiresTools(["python"])]
[ActiveIssue("https://github.com/dotnet/aspire/issues/8466")]
public async Task PythonResourceFinishesSuccessfully()
{
var (projectDirectory, _, scriptName) = CreateTempPythonProject(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
builder.AddPythonApp("pyproj", projectDirectory, scriptName);
using var app = builder.Build();
await app.StartAsync();
await app.ResourceNotifications.WaitForResourceAsync("pyproj", "Finished").WaitAsync(TimeSpan.FromSeconds(30));
await app.StopAsync();
// If we don't throw, clean up the directories.
Directory.Delete(projectDirectory, true);
}
[Fact]
[RequiresTools(["python"])]
public async Task PythonResourceSupportsWithReference()
{
var (projectDirectory, _, scriptName) = CreateTempPythonProject(outputHelper);
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
var externalResource = builder.AddConnectionString("connectionString");
builder.Configuration["ConnectionStrings:connectionString"] = "test";
var pyproj = builder.AddPythonApp("pyproj", projectDirectory, scriptName)
.WithReference(externalResource);
using var app = builder.Build();
var environmentVariables = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(pyproj.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
Assert.Equal("test", environmentVariables["ConnectionStrings__connectionString"]);
// If we don't throw, clean up the directories.
Directory.Delete(projectDirectory, true);
}
[Fact]
[RequiresTools(["python"])]
public async Task AddPythonApp_SetsResourcePropertiesCorrectly()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
var (projectDirectory, pythonExecutable, scriptName) = CreateTempPythonProject(outputHelper);
builder.AddPythonApp("pythonProject", projectDirectory, scriptName);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
// Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists)
var pythonProjectResource = executableResources.OfType<PythonAppResource>().Single();
Assert.Equal("pythonProject", pythonProjectResource.Name);
Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory);
if (OperatingSystem.IsWindows())
{
Assert.Equal(Path.Join(projectDirectory, ".venv", "Scripts", "python.exe"), pythonProjectResource.Command);
}
else
{
Assert.Equal(Path.Join(projectDirectory, ".venv", "bin", "python"), pythonProjectResource.Command);
}
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
Assert.Equal(scriptName, commandArguments[0]);
// If we don't throw, clean up the directories.
Directory.Delete(projectDirectory, true);
}
[Fact]
[RequiresTools(["python"])]
public async Task AddPythonApp_ObsoleteMethod_StillWorks()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
var (projectDirectory, pythonExecutable, scriptName) = CreateTempPythonProject(outputHelper);
#pragma warning disable CS0618 // Type or member is obsolete
builder.AddPythonApp("pythonProject", projectDirectory, scriptName);
#pragma warning restore CS0618 // Type or member is obsolete
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
// Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists)
var pythonProjectResource = executableResources.OfType<PythonAppResource>().Single();
Assert.Equal("pythonProject", pythonProjectResource.Name);
Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory);
if (OperatingSystem.IsWindows())
{
Assert.Equal(Path.Join(projectDirectory, ".venv", "Scripts", "python.exe"), pythonProjectResource.Command);
}
else
{
Assert.Equal(Path.Join(projectDirectory, ".venv", "bin", "python"), pythonProjectResource.Command);
}
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
Assert.Equal(scriptName, commandArguments[0]);
// Verify it creates a script entrypoint
var resource = appModel.Resources.OfType<PythonAppResource>().Single();
var entrypointAnnotation = resource.Annotations.OfType<PythonEntrypointAnnotation>().Single();
Assert.Equal(EntrypointType.Script, entrypointAnnotation.Type);
// If we don't throw, clean up the directories.
Directory.Delete(projectDirectory, true);
}
[Fact]
[RequiresTools(["python"])]
public async Task AddPythonAppWithScriptArgs_IncludesTheArguments()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
var (projectDirectory, pythonExecutable, scriptName) = CreateTempPythonProject(outputHelper);
builder.AddPythonApp("pythonProject", projectDirectory, scriptName)
.WithArgs("test");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
// Filter to get only the PythonAppResource (pip installer may also be present if requirements.txt exists)
var pythonProjectResource = executableResources.OfType<PythonAppResource>().Single();
Assert.Equal("pythonProject", pythonProjectResource.Name);
Assert.Equal(projectDirectory, pythonProjectResource.WorkingDirectory);
if (OperatingSystem.IsWindows())
{
Assert.Equal(Path.Join(projectDirectory, ".venv", "Scripts", "python.exe"), pythonProjectResource.Command);
}
else
{
Assert.Equal(Path.Join(projectDirectory, ".venv", "bin", "python"), pythonProjectResource.Command);
}
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
Assert.Equal(scriptName, commandArguments[0]);
Assert.Equal("test", commandArguments[1]);
// If we don't throw, clean up the directories.
Directory.Delete(projectDirectory, true);
}
private static (string projectDirectory, string pythonExecutable, string scriptName) CreateTempPythonProject(ITestOutputHelper outputHelper, bool instrument = false)
{
var projectDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(projectDirectory);
if (instrument)
{
PreparePythonProject(outputHelper, projectDirectory, PythonApp, InstrumentedPythonAppRequirements);
}
else
{
PreparePythonProject(outputHelper, projectDirectory, PythonApp);
}
var pythonExecutable = Path.Combine(projectDirectory,
".venv",
OperatingSystem.IsWindows() ? "Scripts" : "bin",
OperatingSystem.IsWindows() ? "python.exe" : "python"
);
return (projectDirectory, pythonExecutable, "main.py");
}
private static void PreparePythonProject(ITestOutputHelper outputHelper, string projectDirectory, string scriptContent, string? requirementsContent = null)
{
var scriptPath = Path.Combine(projectDirectory, "main.py");
File.WriteAllText(scriptPath, scriptContent);
var requirementsPath = Path.Combine(projectDirectory, "requirements.txt");
File.WriteAllText(requirementsPath, requirementsContent);
// This dockerfile doesn't *need* to work but it's a good sanity check.
var dockerFilePath = Path.Combine(projectDirectory, "Dockerfile");
File.WriteAllText(dockerFilePath,
"""
FROM python:3.9
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "main.py"]
""");
var prepareVirtualEnvironmentStartInfo = new ProcessStartInfo()
{
FileName = "python",
Arguments = $"-m venv .venv",
WorkingDirectory = projectDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true
};
var createVirtualEnvironmentProcess = Process.Start(prepareVirtualEnvironmentStartInfo);
var createVirtualEnvironmentProcessResult = createVirtualEnvironmentProcess!.WaitForExit(TimeSpan.FromMinutes(2));
outputHelper.WriteLine("Create Virtual Environment Standard Output:");
CopyStreamToTestOutput("python -m venv .venv (Standard Output)", createVirtualEnvironmentProcess.StandardOutput, outputHelper);
CopyStreamToTestOutput("python -m venv .venv (Standard Error)", createVirtualEnvironmentProcess.StandardError, outputHelper);
if (!createVirtualEnvironmentProcessResult)
{
createVirtualEnvironmentProcess.Kill(true);
throw new InvalidOperationException("Failed to create virtual environment.");
}
var relativePipPath = Path.Combine(
".venv",
OperatingSystem.IsWindows() ? "Scripts" : "bin",
OperatingSystem.IsWindows() ? "pip.exe" : "pip"
);
var pipPath = Path.GetFullPath(relativePipPath, projectDirectory);
var installRequirementsStartInfo = new ProcessStartInfo()
{
FileName = pipPath,
Arguments = $"install -q -r requirements.txt",
WorkingDirectory = projectDirectory,
RedirectStandardOutput = true,
RedirectStandardError = true
};
var installRequirementsProcess = Process.Start(installRequirementsStartInfo);
var installRequirementsProcessResult = installRequirementsProcess!.WaitForExit(TimeSpan.FromMinutes(2));
CopyStreamToTestOutput("pip install -r requirements.txt (Standard Output)", installRequirementsProcess.StandardOutput, outputHelper);
CopyStreamToTestOutput("pip install -r requirements.txt (Standard Error)", installRequirementsProcess.StandardError, outputHelper);
if (!installRequirementsProcessResult)
{
installRequirementsProcess.Kill(true);
throw new InvalidOperationException("Failed to install requirements.");
}
}
private static void CopyStreamToTestOutput(string label, StreamReader reader, ITestOutputHelper outputHelper)
{
var output = reader.ReadToEnd();
outputHelper.WriteLine($"{label}:\n\n{output}");
}
private static void AssertPythonCommandPath(string expectedVenvPath, string actualCommand)
{
var expectedCommand = OperatingSystem.IsWindows()
? Path.Join(expectedVenvPath, "Scripts", "python.exe")
: Path.Join(expectedVenvPath, "bin", "python");
Assert.Equal(expectedCommand, actualCommand);
}
private const string PythonApp = """"
import logging
# Reset the logging configuration to a sensible default.
logging.basicConfig()
logging.getLogger().setLevel(logging.NOTSET)
# Write a basic log message.
logging.getLogger(__name__).info("Hello world!")
"""";
private const string InstrumentedPythonAppRequirements = """"
opentelemetry-distro[otlp]
"""";
[Fact]
public void AddPythonApp_DoesNotThrowOnMissingVirtualEnvironment()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
// Should not throw - validation is deferred until runtime
var exception = Record.Exception(() =>
builder.AddPythonApp("pythonProject", tempDir.Path, "main.py"));
Assert.Null(exception);
}
[Fact]
public async Task WithVirtualEnvironment_UpdatesCommandToUseNewVirtualEnvironment()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithVirtualEnvironment("custom-venv");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path));
if (OperatingSystem.IsWindows())
{
Assert.Equal(Path.Join(expectedProjectDirectory, "custom-venv", "Scripts", "python.exe"), pythonProjectResource.Command);
}
else
{
Assert.Equal(Path.Join(expectedProjectDirectory, "custom-venv", "bin", "python"), pythonProjectResource.Command);
}
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
Assert.Equal(scriptName, commandArguments[0]);
}
[Fact]
public async Task WithVirtualEnvironment_SupportsAbsolutePath()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
using var tempVenvDir = new TempDirectory();
var scriptName = "main.py";
builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithVirtualEnvironment(tempVenvDir.Path);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
if (OperatingSystem.IsWindows())
{
Assert.Equal(Path.Join(tempVenvDir.Path, "Scripts", "python.exe"), pythonProjectResource.Command);
}
else
{
Assert.Equal(Path.Join(tempVenvDir.Path, "bin", "python"), pythonProjectResource.Command);
}
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
Assert.Equal(scriptName, commandArguments[0]);
}
[Fact]
public void WithVirtualEnvironment_ThrowsOnNullBuilder()
{
IResourceBuilder<PythonAppResource> builder = null!;
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.WithVirtualEnvironment("some-venv"));
Assert.Equal("builder", exception.ParamName);
}
[Fact]
public void WithVirtualEnvironment_ThrowsOnNullOrEmptyPath()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
var resourceBuilder = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName);
var nullException = Assert.Throws<ArgumentNullException>(() =>
resourceBuilder.WithVirtualEnvironment(null!));
Assert.Equal("virtualEnvironmentPath", nullException.ParamName);
var emptyException = Assert.Throws<ArgumentException>(() =>
resourceBuilder.WithVirtualEnvironment(string.Empty));
Assert.Equal("virtualEnvironmentPath", emptyException.ParamName);
}
[Fact]
public async Task WithVirtualEnvironment_CanBeChainedWithOtherExtensions()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
var resourceBuilder = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithVirtualEnvironment(".venv")
.WithArgs("arg1", "arg2")
.WithEnvironment("TEST_VAR", "test_value");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
Assert.Equal(3, commandArguments.Count);
Assert.Equal(scriptName, commandArguments[0]);
Assert.Equal("arg1", commandArguments[1]);
Assert.Equal("arg2", commandArguments[2]);
var environmentVariables = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
pythonProjectResource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
Assert.Equal("test_value", environmentVariables["TEST_VAR"]);
}
[Fact]
public void WithVirtualEnvironment_UsesAppDirectoryWhenVenvExistsThere()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempAppDir = new TempDirectory();
// Create .venv in the app directory
var appVenvPath = Path.Combine(tempAppDir.Path, ".venv");
Directory.CreateDirectory(appVenvPath);
var scriptName = "main.py";
var resourceBuilder = builder.AddPythonApp("pythonProject", tempAppDir.Path, scriptName);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
// Should use the app directory .venv since it exists there
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path));
var expectedVenvPath = Path.Combine(expectedProjectDirectory, ".venv");
AssertPythonCommandPath(expectedVenvPath, pythonProjectResource.Command);
}
[Fact]
public void WithVirtualEnvironment_UsesAppHostDirectoryWhenVenvOnlyExistsThere()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempAppDir = new TempDirectory();
// Create app directory as a subdirectory of AppHost (realistic scenario)
var appDirName = "python-app";
var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName);
Directory.CreateDirectory(appDirPath);
// Create .venv in the AppHost directory (not in app directory)
var appHostVenvPath = Path.Combine(builder.AppHostDirectory, ".venv");
Directory.CreateDirectory(appHostVenvPath);
try
{
var scriptName = "main.py";
var resourceBuilder = builder.AddPythonApp("pythonProject", appDirName, scriptName);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
// Should use the AppHost directory .venv since it only exists there
AssertPythonCommandPath(appHostVenvPath, pythonProjectResource.Command);
}
finally
{
// Clean up
if (Directory.Exists(appDirPath))
{
Directory.Delete(appDirPath, true);
}
if (Directory.Exists(appHostVenvPath))
{
Directory.Delete(appHostVenvPath, true);
}
}
}
[Fact]
public void WithVirtualEnvironment_PrefersAppDirectoryWhenVenvExistsInBoth()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
// Create app directory as a subdirectory of AppHost (realistic scenario)
var appDirName = "python-app";
var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName);
Directory.CreateDirectory(appDirPath);
// Create .venv in both directories
var appVenvPath = Path.Combine(appDirPath, ".venv");
Directory.CreateDirectory(appVenvPath);
var appHostVenvPath = Path.Combine(builder.AppHostDirectory, ".venv");
Directory.CreateDirectory(appHostVenvPath);
try
{
var scriptName = "main.py";
var resourceBuilder = builder.AddPythonApp("pythonProject", appDirName, scriptName);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
// Should prefer the app directory .venv when it exists in both locations
AssertPythonCommandPath(appVenvPath, pythonProjectResource.Command);
}
finally
{
// Clean up
if (Directory.Exists(appDirPath))
{
Directory.Delete(appDirPath, true);
}
if (Directory.Exists(appHostVenvPath))
{
Directory.Delete(appHostVenvPath, true);
}
}
}
[Fact]
public void WithVirtualEnvironment_DefaultsToAppDirectoryWhenVenvExistsInNeither()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempAppDir = new TempDirectory();
// Don't create .venv in either directory
var scriptName = "main.py";
var resourceBuilder = builder.AddPythonApp("pythonProject", tempAppDir.Path, scriptName);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
// Should default to app directory when it doesn't exist in either location
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempAppDir.Path));
var expectedVenvPath = Path.Combine(expectedProjectDirectory, ".venv");
AssertPythonCommandPath(expectedVenvPath, pythonProjectResource.Command);
}
[Fact]
public void WithVirtualEnvironment_ExplicitPath_UsesVerbatim()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
// Create app directory as a subdirectory of AppHost
var appDirName = "python-app";
var appDirPath = Path.Combine(builder.AppHostDirectory, appDirName);
Directory.CreateDirectory(appDirPath);
// Create .venv in the AppHost directory
var appHostVenvPath = Path.Combine(builder.AppHostDirectory, ".venv");
Directory.CreateDirectory(appHostVenvPath);
// Create a custom venv in the app directory
var customVenvPath = Path.Combine(appDirPath, "custom-venv");
Directory.CreateDirectory(customVenvPath);
try
{
var scriptName = "main.py";
// Explicitly specify a custom venv path - should use it verbatim, not fall back to AppHost .venv
var resourceBuilder = builder.AddPythonApp("pythonProject", appDirName, scriptName)
.WithVirtualEnvironment("custom-venv");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
// Should use the explicitly specified path, NOT the AppHost .venv
AssertPythonCommandPath(customVenvPath, pythonProjectResource.Command);
}
finally
{
// Clean up
if (Directory.Exists(appDirPath))
{
Directory.Delete(appDirPath, true);
}
if (Directory.Exists(appHostVenvPath))
{
Directory.Delete(appHostVenvPath, true);
}
}
}
[Fact]
public void WithUv_CreatesUvEnvironmentResource()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithUv();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify the installer resource exists
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().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<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Equal("uv", packageManager.ExecutableName);
// Verify the install command annotation
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonInstallCommandAnnotation>(out var installAnnotation));
var arg = Assert.Single(installAnnotation.Args);
Assert.Equal("sync", arg);
}
[Fact]
public async Task WithUv_AddsUvSyncArgument()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithUv();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify the install command annotation has the correct args
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonInstallCommandAnnotation>(out var installAnnotation));
var arg = Assert.Single(installAnnotation.Args);
Assert.Equal("sync", arg);
}
[Fact]
public async Task WithUv_AddsWaitForCompletionRelationship()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithUv();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Manually trigger BeforeStartEvent to wire up wait dependencies
await PublishBeforeStartEventAsync(app);
var pythonAppResource = appModel.Resources.OfType<PythonAppResource>().Single();
var uvEnvironmentResource = appModel.Resources.OfType<PythonInstallerResource>().Single();
var waitAnnotations = pythonAppResource.Annotations.OfType<WaitAnnotation>();
var waitForCompletionAnnotation = Assert.Single(waitAnnotations);
Assert.Equal(uvEnvironmentResource, waitForCompletionAnnotation.Resource);
Assert.Equal(WaitType.WaitForCompletion, waitForCompletionAnnotation.WaitType);
}
[Fact]
public void WithUv_ThrowsOnNullBuilder()
{
IResourceBuilder<PythonAppResource> builder = null!;
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.WithUv());
Assert.Equal("builder", exception.ParamName);
}
[Fact]
public void WithUv_IsIdempotent()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
// Call WithUv twice
var pythonBuilder = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithUv()
.WithUv();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify that only one UV environment resource was created
var uvEnvironmentResource = appModel.Resources.OfType<PythonInstallerResource>().Single();
Assert.Equal("pythonProject-installer", uvEnvironmentResource.Name);
}
[Fact]
public void WithPip_AfterWithUv_ReplacesPackageManager()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
// Call WithUv then WithPip - WithPip should replace WithUv
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithUv()
.WithPip();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify that only one installer resource was created
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().Single();
Assert.Equal("pythonProject-installer", installerResource.Name);
// Verify that pip is the active package manager (not uv)
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Contains("pip", packageManager.ExecutableName);
// Verify the install command is for pip (not uv sync)
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonInstallCommandAnnotation>(out var installAnnotation));
Assert.Equal("install", installAnnotation.Args[0]);
Assert.Equal("-r", installAnnotation.Args[1]);
Assert.Equal("requirements.txt", installAnnotation.Args[2]);
}
[Fact]
public void WithUv_AfterWithPip_ReplacesPackageManager()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
// Call WithPip then WithUv - WithUv should replace WithPip
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithPip()
.WithUv();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify that only one installer resource was created
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().Single();
Assert.Equal("pythonProject-installer", installerResource.Name);
// Verify that uv is the active package manager (not pip)
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Equal("uv", packageManager.ExecutableName);
// Verify the install command is for uv (not pip install)
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonInstallCommandAnnotation>(out var installAnnotation));
var arg = Assert.Single(installAnnotation.Args);
Assert.Equal("sync", arg);
}
[Fact]
public void AddPythonApp_CreatesResourceWithScriptEntrypoint()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonBuilder = builder.AddPythonApp("python-script", tempDir.Path, "main.py");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resource = Assert.Single(appModel.Resources.OfType<PythonAppResource>());
Assert.Equal("python-script", resource.Name);
var entrypointAnnotation = resource.Annotations.OfType<PythonEntrypointAnnotation>().Single();
Assert.Equal(EntrypointType.Script, entrypointAnnotation.Type);
Assert.Equal("main.py", entrypointAnnotation.Entrypoint);
}
[Fact]
public void AddPythonModule_CreatesResourceWithModuleEntrypoint()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonBuilder = builder.AddPythonModule("flask-app", tempDir.Path, "flask");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resource = Assert.Single(appModel.Resources.OfType<PythonAppResource>());
Assert.Equal("flask-app", resource.Name);
var entrypointAnnotation = resource.Annotations.OfType<PythonEntrypointAnnotation>().Single();
Assert.Equal(EntrypointType.Module, entrypointAnnotation.Type);
Assert.Equal("flask", entrypointAnnotation.Entrypoint);
}
[Fact]
public void AddPythonExecutable_CreatesResourceWithExecutableEntrypoint()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonBuilder = builder.AddPythonExecutable("pytest", tempDir.Path, "pytest");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resource = Assert.Single(appModel.Resources.OfType<PythonAppResource>());
Assert.Equal("pytest", resource.Name);
var entrypointAnnotation = resource.Annotations.OfType<PythonEntrypointAnnotation>().Single();
Assert.Equal(EntrypointType.Executable, entrypointAnnotation.Type);
Assert.Equal("pytest", entrypointAnnotation.Entrypoint);
}
[Fact]
public async Task AddPythonApp_SetsCorrectCommandAndArguments()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
builder.AddPythonApp("pythonProject", tempDir.Path, scriptName);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
Assert.Equal("pythonProject", pythonProjectResource.Name);
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path));
Assert.Equal(expectedProjectDirectory, pythonProjectResource.WorkingDirectory);
if (OperatingSystem.IsWindows())
{
Assert.Equal(Path.Join(expectedProjectDirectory, ".venv", "Scripts", "python.exe"), pythonProjectResource.Command);
}
else
{
Assert.Equal(Path.Join(expectedProjectDirectory, ".venv", "bin", "python"), pythonProjectResource.Command);
}
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
Assert.Single(commandArguments);
Assert.Equal(scriptName, commandArguments[0]);
}
[Fact]
public async Task AddPythonModule_SetsCorrectCommandAndArguments()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var moduleName = "flask";
builder.AddPythonModule("pythonProject", tempDir.Path, moduleName);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path));
if (OperatingSystem.IsWindows())
{
Assert.Equal(Path.Join(expectedProjectDirectory, ".venv", "Scripts", "python.exe"), pythonProjectResource.Command);
}
else
{
Assert.Equal(Path.Join(expectedProjectDirectory, ".venv", "bin", "python"), pythonProjectResource.Command);
}
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
Assert.Equal(2, commandArguments.Count);
Assert.Equal("-m", commandArguments[0]);
Assert.Equal(moduleName, commandArguments[1]);
}
[Fact]
public async Task AddPythonExecutable_SetsCorrectCommandAndArguments()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var executableName = "pytest";
builder.AddPythonExecutable("pythonProject", tempDir.Path, executableName);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources.OfType<PythonAppResource>());
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path));
if (OperatingSystem.IsWindows())
{
Assert.Equal(Path.Join(expectedProjectDirectory, ".venv", "Scripts", $"{executableName}.exe"), pythonProjectResource.Command);
}
else
{
Assert.Equal(Path.Join(expectedProjectDirectory, ".venv", "bin", executableName), pythonProjectResource.Command);
}
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(pythonProjectResource, TestServiceProvider.Instance);
// Executable doesn't add entrypoint to args
Assert.Empty(commandArguments);
}
[Fact]
public async Task AddPythonModule_WithArgs_AddsArgumentsCorrectly()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonBuilder = builder.AddPythonModule("flask-app", tempDir.Path, "flask")
.WithArgs("run", "--debug", "--host=0.0.0.0");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resource = Assert.Single(appModel.Resources.OfType<PythonAppResource>());
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(resource, TestServiceProvider.Instance);
Assert.Equal(5, commandArguments.Count);
Assert.Equal("-m", commandArguments[0]);
Assert.Equal("flask", commandArguments[1]);
Assert.Equal("run", commandArguments[2]);
Assert.Equal("--debug", commandArguments[3]);
Assert.Equal("--host=0.0.0.0", commandArguments[4]);
}
[Fact]
public async Task AddPythonApp_WithArgs_AddsArgumentsCorrectly()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonBuilder = builder.AddPythonApp("python-app", tempDir.Path, "main.py")
.WithArgs("arg1", "arg2");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resource = Assert.Single(appModel.Resources.OfType<PythonAppResource>());
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(resource, TestServiceProvider.Instance);
Assert.Equal(3, commandArguments.Count);
Assert.Equal("main.py", commandArguments[0]);
Assert.Equal("arg1", commandArguments[1]);
Assert.Equal("arg2", commandArguments[2]);
}
[Fact]
public async Task AddPythonExecutable_WithArgs_AddsArgumentsCorrectly()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonBuilder = builder.AddPythonExecutable("pytest", tempDir.Path, "pytest")
.WithArgs("-q", "--verbose");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resource = Assert.Single(appModel.Resources.OfType<PythonAppResource>());
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(resource, TestServiceProvider.Instance);
Assert.Equal(2, commandArguments.Count);
Assert.Equal("-q", commandArguments[0]);
Assert.Equal("--verbose", commandArguments[1]);
}
[Fact]
public async Task WithEntrypoint_ChangesEntrypointTypeAndValue()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
// Start with a script
var pythonBuilder = builder.AddPythonApp("python-app", tempDir.Path, "main.py")
.WithArgs("arg1", "arg2");
// Change to a module
pythonBuilder.WithEntrypoint(EntrypointType.Module, "uvicorn")
.WithArgs("main:app", "--reload");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var resource = Assert.Single(appModel.Resources.OfType<PythonAppResource>());
// Verify the entrypoint was updated
var entrypointAnnotation = resource.Annotations.OfType<PythonEntrypointAnnotation>().Single();
Assert.Equal(EntrypointType.Module, entrypointAnnotation.Type);
Assert.Equal("uvicorn", entrypointAnnotation.Entrypoint);
// Verify arguments
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(resource, TestServiceProvider.Instance);
Assert.Equal(4, commandArguments.Count);
Assert.Equal("-m", commandArguments[0]);
Assert.Equal("uvicorn", commandArguments[1]);
Assert.Equal("main:app", commandArguments[2]);
Assert.Equal("--reload", commandArguments[3]);
}
[Fact]
public void WithEntrypoint_UpdatesCommandForExecutableType()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
// Start with a script
var pythonBuilder = builder.AddPythonApp("python-app", tempDir.Path, "main.py");
// Get the initial command (should be python executable)
var initialCommand = pythonBuilder.Resource.Command;
// Change to an executable
pythonBuilder.WithEntrypoint(EntrypointType.Executable, "pytest");
var newCommand = pythonBuilder.Resource.Command;
// Commands should be different - one is python, one is pytest
Assert.NotEqual(initialCommand, newCommand);
Assert.Contains("pytest", newCommand);
}
[Fact]
public void WithEntrypoint_ThrowsWhenVirtualEnvironmentNotFound()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
// Create a resource without going through AddPythonApp (missing annotations)
var resource = new PythonAppResource("test", "python", "/tmp");
var resourceBuilder = builder.CreateResourceBuilder(resource);
var exception = Assert.Throws<InvalidOperationException>(() =>
resourceBuilder.WithEntrypoint(EntrypointType.Module, "flask"));
Assert.Contains("Python environment annotation", exception.Message);
}
[Fact]
public void WithEntrypoint_ThrowsOnNullBuilder()
{
IResourceBuilder<PythonAppResource> builder = null!;
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.WithEntrypoint(EntrypointType.Module, "flask"));
Assert.Equal("builder", exception.ParamName);
}
[Fact]
public void WithEntrypoint_ThrowsOnNullOrEmptyEntrypoint()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var resourceBuilder = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py");
var nullException = Assert.Throws<ArgumentNullException>(() =>
resourceBuilder.WithEntrypoint(EntrypointType.Module, null!));
Assert.Equal("entrypoint", nullException.ParamName);
var emptyException = Assert.Throws<ArgumentException>(() =>
resourceBuilder.WithEntrypoint(EntrypointType.Module, string.Empty));
Assert.Equal("entrypoint", emptyException.ParamName);
}
[Fact]
public async Task WithUv_GeneratesDockerfileInPublishMode()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var projectDirectory = sourceDir.Path;
// Create a UV-based Python project with pyproject.toml and uv.lock
var pyprojectContent = """
[project]
name = "test-app"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
""";
var uvLockContent = """
version = 1
requires-python = ">=3.12"
""";
var scriptContent = """
print("Hello from UV project!")
""";
File.WriteAllText(Path.Combine(projectDirectory, "pyproject.toml"), pyprojectContent);
File.WriteAllText(Path.Combine(projectDirectory, "uv.lock"), uvLockContent);
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
var manifestPath = Path.Combine(projectDirectory, "aspire-manifest.json");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
// Add Python resources with different entrypoint types
builder.AddPythonApp("script-app", projectDirectory, "main.py")
.WithUv();
builder.AddPythonModule("module-app", projectDirectory, "mymodule")
.WithUv();
builder.AddPythonExecutable("executable-app", projectDirectory, "pytest")
.WithUv();
var app = builder.Build();
app.Run();
// Verify that Dockerfiles were generated for each entrypoint type
var scriptDockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
Assert.True(File.Exists(scriptDockerfilePath), "Dockerfile should be generated for script entrypoint");
var moduleDockerfilePath = Path.Combine(outputDir.Path, "module-app.Dockerfile");
Assert.True(File.Exists(moduleDockerfilePath), "Dockerfile should be generated for module entrypoint");
var executableDockerfilePath = Path.Combine(outputDir.Path, "executable-app.Dockerfile");
Assert.True(File.Exists(executableDockerfilePath), "Dockerfile should be generated for executable entrypoint");
var scriptDockerfileContent = File.ReadAllText(scriptDockerfilePath);
var moduleDockerfileContent = File.ReadAllText(moduleDockerfilePath);
var executableDockerfileContent = File.ReadAllText(executableDockerfilePath);
await Verify(scriptDockerfileContent)
.AppendContentAsFile(moduleDockerfileContent)
.AppendContentAsFile(executableDockerfileContent);
}
[Fact]
public async Task WithUv_GeneratesDockerfileInPublishMode_WithoutUvLock()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var projectDirectory = sourceDir.Path;
// Create a UV-based Python project with pyproject.toml but NO uv.lock
var pyprojectContent = """
[project]
name = "test-app"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
""";
var scriptContent = """
print("Hello from UV project!")
""";
File.WriteAllText(Path.Combine(projectDirectory, "pyproject.toml"), pyprojectContent);
// Note: NO uv.lock file created
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
var manifestPath = Path.Combine(projectDirectory, "aspire-manifest.json");
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
// Add Python resources with different entrypoint types
builder.AddPythonApp("script-app", projectDirectory, "main.py")
.WithUv();
builder.AddPythonModule("module-app", projectDirectory, "mymodule")
.WithUv();
builder.AddPythonExecutable("executable-app", projectDirectory, "pytest")
.WithUv();
var app = builder.Build();
app.Run();
// Verify that Dockerfiles were generated for each entrypoint type
var scriptDockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
Assert.True(File.Exists(scriptDockerfilePath), "Dockerfile should be generated for script entrypoint");
var moduleDockerfilePath = Path.Combine(outputDir.Path, "module-app.Dockerfile");
Assert.True(File.Exists(moduleDockerfilePath), "Dockerfile should be generated for module entrypoint");
var executableDockerfilePath = Path.Combine(outputDir.Path, "executable-app.Dockerfile");
Assert.True(File.Exists(executableDockerfilePath), "Dockerfile should be generated for executable entrypoint");
var scriptDockerfileContent = File.ReadAllText(scriptDockerfilePath);
var moduleDockerfileContent = File.ReadAllText(moduleDockerfilePath);
var executableDockerfileContent = File.ReadAllText(executableDockerfilePath);
// Verify the Dockerfiles don't use --locked flag
Assert.DoesNotContain("--locked", scriptDockerfileContent);
Assert.DoesNotContain("--locked", moduleDockerfileContent);
Assert.DoesNotContain("--locked", executableDockerfileContent);
await Verify(scriptDockerfileContent)
.AppendContentAsFile(moduleDockerfileContent)
.AppendContentAsFile(executableDockerfileContent);
}
[Fact]
public async Task WithDebugSupport_RemovesScriptArgumentForScriptEntrypoint()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var tempDir = new TempDirectory();
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = ["python"]
};
// Set DEBUG_SESSION_INFO to trigger VS Code debug support callback
builder.Configuration["DEBUG_SESSION_INFO"] = JsonSerializer.Serialize(runSessionInfo);
builder.Configuration["DEBUG_SESSION_PORT"] = "5678";
var appDirectory = Path.Combine(tempDir.Path, "myapp");
Directory.CreateDirectory(appDirectory);
var virtualEnvironmentPath = Path.Combine(tempDir.Path, ".venv");
Directory.CreateDirectory(virtualEnvironmentPath);
var scriptPath = "main.py";
var pythonApp = builder.AddPythonApp("myapp", appDirectory, scriptPath)
.WithVirtualEnvironment(virtualEnvironmentPath)
.WithArgs("arg1", "arg2");
var app = builder.Build();
var resource = pythonApp.Resource;
// Use ArgumentEvaluator to get the resolved argument list (after callbacks are applied)
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(resource, app.Services);
// Verify the script path was removed but other args remain
Assert.Collection(commandArguments,
arg => Assert.Equal("arg1", arg),
arg => Assert.Equal("arg2", arg));
}
[Fact]
public async Task WithDebugSupport_DoesntRemoveScriptArgumentForScriptEntrypoint_WhenResourceTypeNotSupported()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var tempDir = new TempDirectory();
// Set DEBUG_SESSION_INFO to trigger VS Code debug support callback
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"]
};
builder.Configuration["DEBUG_SESSION_INFO"] = JsonSerializer.Serialize(runSessionInfo);
builder.Configuration["DEBUG_SESSION_PORT"] = "5678";
var appDirectory = Path.Combine(tempDir.Path, "myapp");
Directory.CreateDirectory(appDirectory);
var virtualEnvironmentPath = Path.Combine(tempDir.Path, ".venv");
Directory.CreateDirectory(virtualEnvironmentPath);
var scriptPath = "main.py";
var pythonApp = builder.AddPythonApp("myapp", appDirectory, scriptPath)
.WithVirtualEnvironment(virtualEnvironmentPath)
.WithArgs("arg1", "arg2");
var app = builder.Build();
var resource = pythonApp.Resource;
// Use ArgumentEvaluator to get the resolved argument list (after callbacks are applied)
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(resource, app.Services);
// Verify the script path was removed but other args remain
Assert.Collection(commandArguments,
arg => Assert.Equal("main.py", arg),
arg => Assert.Equal("arg1", arg),
arg => Assert.Equal("arg2", arg));
}
[Fact]
public async Task WithDebugSupport_RemovesModuleArgumentsForModuleEntrypoint()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var tempDir = new TempDirectory();
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"],
SupportedLaunchConfigurations = ["python"]
};
// Set DEBUG_SESSION_INFO to trigger VS Code debug support callback
builder.Configuration["DEBUG_SESSION_INFO"] = JsonSerializer.Serialize(runSessionInfo);
builder.Configuration["DEBUG_SESSION_PORT"] = "5678";
var appDirectory = Path.Combine(tempDir.Path, "myapp");
Directory.CreateDirectory(appDirectory);
var virtualEnvironmentPath = Path.Combine(tempDir.Path, ".venv");
Directory.CreateDirectory(virtualEnvironmentPath);
var moduleName = "flask";
var pythonApp = builder.AddPythonModule("myapp", appDirectory, moduleName)
.WithVirtualEnvironment(virtualEnvironmentPath)
.WithArgs("run");
var app = builder.Build();
var resource = pythonApp.Resource;
// Use ArgumentEvaluator to get the resolved argument list (after callbacks are applied)
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(resource, app.Services);
// Verify "-m" and module name were removed but other args remain
Assert.Collection(commandArguments,
arg => Assert.Equal("run", arg));
}
[Fact]
public async Task WithDebugSupport_DoesntRemoveModuleArgumentsForModuleEntrypoint_WhenResourceTypeNotSupported()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var tempDir = new TempDirectory();
var runSessionInfo = new RunSessionInfo
{
ProtocolsSupported = ["test"]
};
// Set DEBUG_SESSION_INFO to trigger VS Code debug support callback
builder.Configuration["DEBUG_SESSION_INFO"] = JsonSerializer.Serialize(runSessionInfo);
builder.Configuration["DEBUG_SESSION_PORT"] = "5678";
var appDirectory = Path.Combine(tempDir.Path, "myapp");
Directory.CreateDirectory(appDirectory);
var virtualEnvironmentPath = Path.Combine(tempDir.Path, ".venv");
Directory.CreateDirectory(virtualEnvironmentPath);
var moduleName = "flask";
var pythonApp = builder.AddPythonModule("myapp", appDirectory, moduleName)
.WithVirtualEnvironment(virtualEnvironmentPath)
.WithArgs("run");
var app = builder.Build();
var resource = pythonApp.Resource;
// Use ArgumentEvaluator to get the resolved argument list (after callbacks are applied)
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(resource, app.Services);
// Verify "-m" and module name were removed but other args remain
Assert.Collection(commandArguments,
arg => Assert.Equal("-m", arg),
arg => Assert.Equal("flask", arg),
arg => Assert.Equal("run", arg));
}
[Fact]
public async Task WithDebugSupport_ExecutableTypeDoesNotModifyArgs()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run);
using var tempDir = new TempDirectory();
// Set DEBUG_SESSION_INFO to trigger VS Code debug support callback
builder.Configuration["DEBUG_SESSION_INFO"] = "{}";
var appDirectory = Path.Combine(tempDir.Path, "myapp");
Directory.CreateDirectory(appDirectory);
var virtualEnvironmentPath = Path.Combine(tempDir.Path, ".venv");
Directory.CreateDirectory(virtualEnvironmentPath);
var executableName = "myexe";
var pythonApp = builder.AddPythonExecutable("myapp", appDirectory, executableName)
.WithVirtualEnvironment(virtualEnvironmentPath)
.WithArgs("arg1", "arg2");
var resource = pythonApp.Resource;
var app = builder.Build();
// Use ArgumentEvaluator to get the resolved argument list (after callbacks are applied)
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(resource, TestServiceProvider.Instance);
// For executable type, no args are removed (no debug support callback)
Assert.Collection(commandArguments,
arg => Assert.Equal("arg1", arg),
arg => Assert.Equal("arg2", arg));
}
[Fact]
public async Task PythonApp_SetsPythonUtf8EnvironmentVariable_OnWindowsInRunMode()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py");
var app = builder.Build();
var environmentVariables = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
pythonApp.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
if (OperatingSystem.IsWindows())
{
Assert.Equal("1", environmentVariables["PYTHONUTF8"]);
}
else
{
Assert.False(environmentVariables.ContainsKey("PYTHONUTF8"));
}
}
[Fact]
public async Task PythonApp_DoesNotSetPythonUtf8EnvironmentVariable_OnNonWindowsPlatforms()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Run).WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py");
var app = builder.Build();
var environmentVariables = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
pythonApp.Resource, DistributedApplicationOperation.Run, TestServiceProvider.Instance);
if (!OperatingSystem.IsWindows())
{
Assert.False(environmentVariables.ContainsKey("PYTHONUTF8"));
}
}
[Fact]
public async Task PythonApp_DoesNotSetPythonUtf8EnvironmentVariable_InPublishMode()
{
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish).WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py");
var app = builder.Build();
var environmentVariables = await EnvironmentVariableEvaluator.GetEnvironmentVariablesAsync(
pythonApp.Resource, DistributedApplicationOperation.Publish, TestServiceProvider.Instance);
// PYTHONUTF8 should not be set in Publish mode, even on Windows
Assert.False(environmentVariables.ContainsKey("PYTHONUTF8"));
}
[Fact]
public async Task WithUv_CustomBaseImages_GeneratesDockerfileWithCustomImages()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var projectDirectory = sourceDir.Path;
// Create a UV-based Python project with pyproject.toml and uv.lock
var pyprojectContent = """
[project]
name = "test-app"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = []
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
""";
var uvLockContent = """
version = 1
requires-python = ">=3.12"
""";
var scriptContent = """
print("Hello from UV project with custom images!")
""";
File.WriteAllText(Path.Combine(projectDirectory, "pyproject.toml"), pyprojectContent);
File.WriteAllText(Path.Combine(projectDirectory, "uv.lock"), uvLockContent);
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
// Add Python resource with custom base images
builder.AddPythonApp("custom-images-app", projectDirectory, "main.py")
.WithUv()
.WithDockerfileBaseImage(
buildImage: "ghcr.io/astral-sh/uv:python3.13-bookworm",
runtimeImage: "python:3.13-slim");
var app = builder.Build();
app.Run();
// Verify that Dockerfile was generated
var dockerfilePath = Path.Combine(outputDir.Path, "custom-images-app.Dockerfile");
Assert.True(File.Exists(dockerfilePath), "Dockerfile should be generated");
var dockerfileContent = File.ReadAllText(dockerfilePath);
// Verify the custom build image is used
Assert.Contains("FROM ghcr.io/astral-sh/uv:python3.13-bookworm AS builder", dockerfileContent);
// Verify the custom runtime image is used
Assert.Contains("FROM python:3.13-slim AS app", dockerfileContent);
}
[Fact]
public async Task FallbackDockerfile_GeneratesDockerfileWithoutUv_WithRequirementsTxt()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var projectDirectory = sourceDir.Path;
// Create a Python project without UV but with requirements.txt
var requirementsContent = """
flask==3.0.0
requests==2.31.0
""";
var scriptContent = """
print("Hello from non-UV project!")
""";
File.WriteAllText(Path.Combine(projectDirectory, "requirements.txt"), requirementsContent);
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
// Add Python resources without UV environment
builder.AddPythonApp("script-app", projectDirectory, "main.py");
var app = builder.Build();
app.Run();
// Verify that Dockerfile was generated
var dockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
Assert.True(File.Exists(dockerfilePath), "Dockerfile should be generated for non-UV Python app");
var dockerfileContent = File.ReadAllText(dockerfilePath);
// Verify it's a fallback Dockerfile (single stage, no UV)
Assert.DoesNotContain("uv sync", dockerfileContent);
Assert.DoesNotContain("ghcr.io/astral-sh/uv", dockerfileContent);
// Verify it uses pip install for requirements.txt
Assert.Contains("pip install --no-cache-dir -r requirements.txt", dockerfileContent);
// Verify it uses the same runtime image as UV workflow
Assert.Contains("FROM python:3.13-slim-bookworm", dockerfileContent);
await Verify(dockerfileContent);
}
[Fact]
public async Task FallbackDockerfile_GeneratesDockerfileWithoutUv_WithoutRequirementsTxt()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var projectDirectory = sourceDir.Path;
// Create a Python project without UV and without requirements.txt
var scriptContent = """
print("Hello from non-UV project with no dependencies!")
""";
var pyprojectContent = """
[project]
name = "test-app"
version = "0.1.0"
requires-python = ">=3.11"
""";
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
File.WriteAllText(Path.Combine(projectDirectory, "pyproject.toml"), pyprojectContent);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
// Add Python resources without UV environment
builder.AddPythonApp("script-app", projectDirectory, "main.py");
var app = builder.Build();
app.Run();
// Verify that Dockerfile was generated
var dockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
Assert.True(File.Exists(dockerfilePath), "Dockerfile should be generated for non-UV Python app");
var dockerfileContent = File.ReadAllText(dockerfilePath);
// Verify it's a fallback Dockerfile (single stage, no UV)
Assert.DoesNotContain("uv sync", dockerfileContent);
Assert.DoesNotContain("ghcr.io/astral-sh/uv", dockerfileContent);
// Verify it doesn't have pip install since there's no requirements.txt
Assert.DoesNotContain("pip install", dockerfileContent);
// Verify it uses the same runtime image as UV workflow
Assert.Contains("FROM python:3.11-slim-bookworm", dockerfileContent);
await Verify(dockerfileContent);
}
[Fact]
public async Task FallbackDockerfile_GeneratesDockerfileForAllEntrypointTypes()
{
using var sourceDir = new TempDirectory();
using var outputDir = new TempDirectory();
var projectDirectory = sourceDir.Path;
// Create a Python project without UV
var scriptContent = """
print("Hello!")
""";
var pythonVersionContent = "3.12";
File.WriteAllText(Path.Combine(projectDirectory, "main.py"), scriptContent);
File.WriteAllText(Path.Combine(projectDirectory, ".python-version"), pythonVersionContent);
using var builder = TestDistributedApplicationBuilder.Create(DistributedApplicationOperation.Publish, outputDir.Path, step: "publish-manifest");
// Add Python resources with different entrypoint types, none using UV
builder.AddPythonApp("script-app", projectDirectory, "main.py");
builder.AddPythonModule("module-app", projectDirectory, "mymodule");
builder.AddPythonExecutable("executable-app", projectDirectory, "pytest");
var app = builder.Build();
app.Run();
// Verify that Dockerfiles were generated for each entrypoint type
var scriptDockerfilePath = Path.Combine(outputDir.Path, "script-app.Dockerfile");
Assert.True(File.Exists(scriptDockerfilePath), "Dockerfile should be generated for script entrypoint");
var moduleDockerfilePath = Path.Combine(outputDir.Path, "module-app.Dockerfile");
Assert.True(File.Exists(moduleDockerfilePath), "Dockerfile should be generated for module entrypoint");
var executableDockerfilePath = Path.Combine(outputDir.Path, "executable-app.Dockerfile");
Assert.True(File.Exists(executableDockerfilePath), "Dockerfile should be generated for executable entrypoint");
var scriptDockerfileContent = File.ReadAllText(scriptDockerfilePath);
var moduleDockerfileContent = File.ReadAllText(moduleDockerfilePath);
var executableDockerfileContent = File.ReadAllText(executableDockerfilePath);
// Verify none use UV
Assert.DoesNotContain("uv sync", scriptDockerfileContent);
Assert.DoesNotContain("uv sync", moduleDockerfileContent);
Assert.DoesNotContain("uv sync", executableDockerfileContent);
// Verify correct entrypoints
Assert.Contains("ENTRYPOINT [\"python\",\"main.py\"]", scriptDockerfileContent);
Assert.Contains("ENTRYPOINT [\"python\",\"-m\",\"mymodule\"]", moduleDockerfileContent);
Assert.Contains("ENTRYPOINT [\"pytest\"]", executableDockerfileContent);
await Verify(scriptDockerfileContent)
.AppendContentAsFile(moduleDockerfileContent)
.AppendContentAsFile(executableDockerfileContent);
}
[Fact]
public void AutoDetection_PyprojectToml_AddsPip()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
var scriptPath = Path.Combine(tempDir.Path, scriptName);
File.WriteAllText(scriptPath, "print('Hello')");
// Create a pyproject.toml file
var pyprojectPath = Path.Combine(tempDir.Path, "pyproject.toml");
File.WriteAllText(pyprojectPath, "[project]\nname = \"test\"");
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName);
var app = builder.Build();
// Verify that WithPip was automatically called (pip supports pyproject.toml)
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Contains("pip", packageManager.ExecutableName);
// Verify that the install command uses pyproject.toml
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonInstallCommandAnnotation>(out var installAnnotation));
Assert.Equal("install", installAnnotation.Args[0]);
Assert.Equal(".", installAnnotation.Args[1]);
// Verify that WithPip created the installer resource
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().SingleOrDefault();
Assert.NotNull(installerResource);
Assert.Equal("pythonProject-installer", installerResource.Name);
}
[Fact]
public void AutoDetection_RequirementsTxt_AddsPip()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
var scriptPath = Path.Combine(tempDir.Path, scriptName);
File.WriteAllText(scriptPath, "print('Hello')");
// Create a requirements.txt file
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests==2.31.0");
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName);
var app = builder.Build();
// Verify that WithPip was automatically called
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Contains("pip", packageManager.ExecutableName);
// Verify that the install command uses requirements.txt
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonInstallCommandAnnotation>(out var installAnnotation));
Assert.Equal("install", installAnnotation.Args[0]);
Assert.Equal("-r", installAnnotation.Args[1]);
Assert.Equal("requirements.txt", installAnnotation.Args[2]);
// Verify that WithPip created the installer resource
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().SingleOrDefault();
Assert.NotNull(installerResource);
Assert.Equal("pythonProject-installer", installerResource.Name);
}
[Fact]
public void AutoDetection_PyprojectToml_TakesPrecedenceOverRequirementsTxt()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
var scriptPath = Path.Combine(tempDir.Path, scriptName);
File.WriteAllText(scriptPath, "print('Hello')");
// Create both pyproject.toml and requirements.txt
var pyprojectPath = Path.Combine(tempDir.Path, "pyproject.toml");
File.WriteAllText(pyprojectPath, "[project]\nname = \"test\"");
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests==2.31.0");
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName);
var app = builder.Build();
// Verify that WithPip was automatically called
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Contains("pip", packageManager.ExecutableName);
// Verify the install command uses pyproject.toml (takes precedence)
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonInstallCommandAnnotation>(out var installAnnotation));
Assert.Equal("install", installAnnotation.Args[0]);
Assert.Equal(".", installAnnotation.Args[1]);
}
[Fact]
public void AutoDetection_NoConfigFile_DoesNotAddPackageManager()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
var scriptPath = Path.Combine(tempDir.Path, scriptName);
File.WriteAllText(scriptPath, "print('Hello')");
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, scriptName);
var app = builder.Build();
// Verify that no package manager was automatically added
Assert.False(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out _));
// Verify that no installer resource was created
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().SingleOrDefault();
Assert.Null(installerResource);
}
[Fact]
public void WithVirtualEnvironment_DisableCreation_DoesNotCreateVenvCreator()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
using var tempVenvDir = new TempDirectory();
var scriptName = "main.py";
var scriptPath = Path.Combine(tempDir.Path, scriptName);
File.WriteAllText(scriptPath, "print('Hello')");
// Add Python script with venv but disable automatic creation
builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: false);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify that no venv creator resource was created
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.Null(venvCreatorResource);
}
[Fact]
public void WithVirtualEnvironment_EnableCreation_CreatesVenvCreator()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
using var tempVenvDir = new TempDirectory();
var scriptName = "main.py";
var scriptPath = Path.Combine(tempDir.Path, scriptName);
File.WriteAllText(scriptPath, "print('Hello')");
// Create a requirements.txt to trigger pip installation
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests");
// Add Python script with venv and enable automatic creation (default)
builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: true);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify that a venv creator resource was created
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.NotNull(venvCreatorResource);
Assert.Equal("pythonProject-venv-creator", venvCreatorResource.Name);
}
[Fact]
public void WithVirtualEnvironment_DefaultBehavior_CreatesVenvCreator()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
using var tempVenvDir = new TempDirectory();
var scriptName = "main.py";
var scriptPath = Path.Combine(tempDir.Path, scriptName);
File.WriteAllText(scriptPath, "print('Hello')");
// Create a requirements.txt to trigger pip installation
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests");
// Add Python script with venv using default behavior (createIfNotExists defaults to true)
builder.AddPythonApp("pythonProject", tempDir.Path, scriptName)
.WithVirtualEnvironment(tempVenvDir.Path);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify that a venv creator resource was created
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.NotNull(venvCreatorResource);
Assert.Equal("pythonProject-venv-creator", venvCreatorResource.Name);
}
// ===== Method Ordering Tests =====
// These tests verify that WithPip, WithUv, and WithVirtualEnvironment work correctly in any order
[Fact]
public void WithUv_DisablesVenvCreation_And_SetsPackageManager()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptPath = Path.Combine(tempDir.Path, "main.py");
File.WriteAllText(scriptPath, "print('Hello')");
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py")
.WithUv();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify uv is the package manager
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Equal("uv", packageManager.ExecutableName);
// Verify NO venv creator was created (uv handles venv itself)
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.Null(venvCreatorResource);
// Verify installer exists
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().SingleOrDefault();
Assert.NotNull(installerResource);
}
[Fact]
public async Task WithPip_CreatesDefaultVenv_And_WaitsForVenvCreation()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptPath = Path.Combine(tempDir.Path, "main.py");
File.WriteAllText(scriptPath, "print('Hello')");
// Create requirements.txt
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests");
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py")
.WithPip();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Manually trigger BeforeStartEvent to wire up wait dependencies
await PublishBeforeStartEventAsync(app);
// Verify pip is the package manager
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Contains("pip", packageManager.ExecutableName);
// Verify default .venv was created
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonEnvironmentAnnotation>(out var envAnnotation));
Assert.NotNull(envAnnotation.VirtualEnvironment);
Assert.Contains(".venv", envAnnotation.VirtualEnvironment.VirtualEnvironmentPath);
// Verify venv creator was created
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.NotNull(venvCreatorResource);
// Verify installer exists and waits for venv creator
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().Single();
var installerWaits = installerResource.Annotations.OfType<WaitAnnotation>()
.Any(w => w.Resource == venvCreatorResource);
Assert.True(installerWaits);
}
[Fact]
public void WithPip_ThenWithVirtualEnvironment_CreateIfNotExistsTrue_CreatesVenv()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
using var tempVenvDir = new TempDirectory();
var scriptPath = Path.Combine(tempDir.Path, "main.py");
File.WriteAllText(scriptPath, "print('Hello')");
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests");
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py")
.WithPip()
.WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: true);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify venv annotation
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonEnvironmentAnnotation>(out var envAnnotation));
Assert.NotNull(envAnnotation.VirtualEnvironment);
Assert.True(envAnnotation.CreateVenvIfNotExists);
// Verify venv creator was created
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.NotNull(venvCreatorResource);
}
[Fact]
public void WithPip_ThenWithVirtualEnvironment_CreateIfNotExistsFalse_DoesNotCreateVenv()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
using var tempVenvDir = new TempDirectory();
var scriptPath = Path.Combine(tempDir.Path, "main.py");
File.WriteAllText(scriptPath, "print('Hello')");
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests");
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py")
.WithPip()
.WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: false);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify venv annotation with createIfNotExists: false
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonEnvironmentAnnotation>(out var envAnnotation));
Assert.NotNull(envAnnotation.VirtualEnvironment);
Assert.False(envAnnotation.CreateVenvIfNotExists);
// Verify NO venv creator was created
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.Null(venvCreatorResource);
// Verify installer still exists
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().SingleOrDefault();
Assert.NotNull(installerResource);
}
[Fact]
public async Task MethodOrdering_WithPip_WithVirtualEnvironment_CreateTrue_WithPip_CreatesVenv()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
using var tempVenvDir = new TempDirectory();
var scriptPath = Path.Combine(tempDir.Path, "main.py");
File.WriteAllText(scriptPath, "print('Hello')");
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests");
// WithPip → WithVirtualEnvironment(createIfNotExists: true) → WithPip again
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py")
.WithPip()
.WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: true)
.WithPip();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Manually trigger BeforeStartEvent to wire up wait dependencies
await PublishBeforeStartEventAsync(app);
// Verify venv creator was created (createIfNotExists: true persists)
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.NotNull(venvCreatorResource);
// Verify installer waits for venv creator
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().Single();
var installerWaits = installerResource.Annotations.OfType<WaitAnnotation>()
.Any(w => w.Resource == venvCreatorResource);
Assert.True(installerWaits);
}
[Fact]
public void MethodOrdering_WithPip_WithVirtualEnvironment_CreateFalse_WithPip_DoesNotCreateVenv()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
using var tempVenvDir = new TempDirectory();
var scriptPath = Path.Combine(tempDir.Path, "main.py");
File.WriteAllText(scriptPath, "print('Hello')");
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests");
// WithPip → WithVirtualEnvironment(createIfNotExists: false) → WithPip again
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py")
.WithPip()
.WithVirtualEnvironment(tempVenvDir.Path, createIfNotExists: false)
.WithPip();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify NO venv creator was created (createIfNotExists: false persists)
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.Null(venvCreatorResource);
// Verify installer still exists
var installerResource = appModel.Resources.OfType<PythonInstallerResource>().SingleOrDefault();
Assert.NotNull(installerResource);
}
[Fact]
public void MethodOrdering_WithPip_ThenWithUv_ReplacesPackageManager_And_DisablesVenvCreation()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptPath = Path.Combine(tempDir.Path, "main.py");
File.WriteAllText(scriptPath, "print('Hello')");
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests");
// WithPip → WithUv (uv should replace pip and disable venv creation)
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py")
.WithPip()
.WithUv();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify uv is the package manager (replaced pip)
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Equal("uv", packageManager.ExecutableName);
// Verify NO venv creator (uv disables venv creation)
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.Null(venvCreatorResource);
// Verify only one installer exists
Assert.Single(appModel.Resources.OfType<PythonInstallerResource>());
}
[Fact]
public void MethodOrdering_WithUv_ThenWithPip_ReplacesPackageManager_And_EnablesVenvCreation()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptPath = Path.Combine(tempDir.Path, "main.py");
File.WriteAllText(scriptPath, "print('Hello')");
var requirementsPath = Path.Combine(tempDir.Path, "requirements.txt");
File.WriteAllText(requirementsPath, "requests");
// WithUv → WithPip (pip should replace uv and enable venv creation)
var pythonApp = builder.AddPythonApp("pythonProject", tempDir.Path, "main.py")
.WithUv()
.WithPip();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify pip is the package manager (replaced uv)
Assert.True(pythonApp.Resource.TryGetLastAnnotation<PythonPackageManagerAnnotation>(out var packageManager));
Assert.Contains("pip", packageManager.ExecutableName);
// Verify venv creator was created (pip enables venv creation)
var venvCreatorResource = appModel.Resources.OfType<PythonVenvCreatorResource>().SingleOrDefault();
Assert.NotNull(venvCreatorResource);
// Verify only one installer exists
Assert.Single(appModel.Resources.OfType<PythonInstallerResource>());
}
/// <summary>
/// Helper method to manually trigger BeforeStartEvent for tests.
/// This is needed because BeforeStartEvent is normally triggered during StartAsync(),
/// but tests often build and assert on the model without starting the application.
/// </summary>
private static async Task PublishBeforeStartEventAsync(DistributedApplication app)
{
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var eventing = app.Services.GetRequiredService<IDistributedApplicationEventing>();
await eventing.PublishAsync(new BeforeStartEvent(app.Services, appModel), CancellationToken.None);
}
}
|