|
// 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
using Microsoft.Extensions.DependencyInjection;
using Aspire.Hosting.Utils;
using Aspire.Hosting.Tests.Utils;
using System.Diagnostics;
using Aspire.TestUtilities;
using Aspire.Hosting.ApplicationModel;
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.AddPythonScript("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.AddPythonScript("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();
var pythonProjectResource = Assert.Single(executableResources);
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();
var pythonProjectResource = Assert.Single(executableResources);
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 AddPythonScriptWithScriptArgs_IncludesTheArguments()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
var (projectDirectory, pythonExecutable, scriptName) = CreateTempPythonProject(outputHelper);
builder.AddPythonScript("pythonProject", projectDirectory, scriptName)
.WithArgs("test");
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources);
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 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 AddPythonScript_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.AddPythonScript("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.AddPythonScript("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);
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.AddPythonScript("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);
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.AddPythonScript("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.AddPythonScript("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);
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 WithUvEnvironment_CreatesUvEnvironmentResource()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
builder.AddPythonScript("pythonProject", tempDir.Path, scriptName)
.WithUvEnvironment();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var uvEnvironmentResource = appModel.Resources.OfType<PythonUvEnvironmentResource>().Single();
Assert.Equal("pythonProject-uv-environment", uvEnvironmentResource.Name);
Assert.Equal("uv", uvEnvironmentResource.Command);
var expectedProjectDirectory = Path.GetFullPath(Path.Combine(builder.AppHostDirectory, tempDir.Path));
Assert.Equal(expectedProjectDirectory, uvEnvironmentResource.WorkingDirectory);
}
[Fact]
public async Task WithUvEnvironment_AddsUvSyncArgument()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
builder.AddPythonScript("pythonProject", tempDir.Path, scriptName)
.WithUvEnvironment();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var uvEnvironmentResource = appModel.Resources.OfType<PythonUvEnvironmentResource>().Single();
var commandArguments = await ArgumentEvaluator.GetArgumentListAsync(uvEnvironmentResource, TestServiceProvider.Instance);
Assert.Single(commandArguments);
Assert.Equal("sync", commandArguments[0]);
}
[Fact]
public void WithUvEnvironment_AddsWaitForCompletionRelationship()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
builder.AddPythonScript("pythonProject", tempDir.Path, scriptName)
.WithUvEnvironment();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var pythonAppResource = appModel.Resources.OfType<PythonAppResource>().Single();
var uvEnvironmentResource = appModel.Resources.OfType<PythonUvEnvironmentResource>().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 WithUvEnvironment_ThrowsOnNullBuilder()
{
IResourceBuilder<PythonAppResource> builder = null!;
var exception = Assert.Throws<ArgumentNullException>(() =>
builder.WithUvEnvironment());
Assert.Equal("builder", exception.ParamName);
}
[Fact]
public void WithUvEnvironment_IsIdempotent()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
// Call WithUvEnvironment twice
var pythonBuilder = builder.AddPythonScript("pythonProject", tempDir.Path, scriptName)
.WithUvEnvironment()
.WithUvEnvironment();
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
// Verify that only one UV environment resource was created
var uvEnvironmentResource = appModel.Resources.OfType<PythonUvEnvironmentResource>().Single();
Assert.Equal("pythonProject-uv-environment", uvEnvironmentResource.Name);
}
[Fact]
public void AddPythonScript_CreatesResourceWithScriptEntrypoint()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonBuilder = builder.AddPythonScript("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 AddPythonScript_SetsCorrectCommandAndArguments()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var scriptName = "main.py";
builder.AddPythonScript("pythonProject", tempDir.Path, scriptName);
var app = builder.Build();
var appModel = app.Services.GetRequiredService<DistributedApplicationModel>();
var executableResources = appModel.GetExecutableResources();
var pythonProjectResource = Assert.Single(executableResources);
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);
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);
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 AddPythonScript_WithArgs_AddsArgumentsCorrectly()
{
using var builder = TestDistributedApplicationBuilder.Create().WithTestAndResourceLogging(outputHelper);
using var tempDir = new TempDirectory();
var pythonBuilder = builder.AddPythonScript("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.AddPythonScript("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.AddPythonScript("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.AddPythonScript("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 WithUvEnvironment_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.AddPythonScript("script-app", projectDirectory, "main.py")
.WithUvEnvironment();
builder.AddPythonModule("module-app", projectDirectory, "mymodule")
.WithUvEnvironment();
builder.AddPythonExecutable("executable-app", projectDirectory, "pytest")
.WithUvEnvironment();
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 WithUvEnvironment_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.AddPythonScript("script-app", projectDirectory, "main.py")
.WithUvEnvironment();
builder.AddPythonModule("module-app", projectDirectory, "mymodule")
.WithUvEnvironment();
builder.AddPythonExecutable("executable-app", projectDirectory, "pytest")
.WithUvEnvironment();
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 WithVSCodeDebugSupport_RemovesScriptArgumentForScriptEntrypoint()
{
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 scriptPath = "main.py";
var pythonApp = builder.AddPythonScript("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 WithVSCodeDebugSupport_RemovesModuleArgumentsForModuleEntrypoint()
{
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 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 WithVSCodeDebugSupport_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.AddPythonScript("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.AddPythonScript("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.AddPythonScript("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"));
}
}
|