Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/Aspire.Cli/Templating/CliTemplateFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,16 @@ private IEnumerable<ITemplate> GetTemplateDefinitions()
languageId: KnownLanguageId.Java,
isEmpty: true),

new CallbackTemplate(
KnownTemplateId.PythonEmptyAppHost,
"Empty (Python AppHost)",
projectName => $"./{projectName}",
cmd => AddOptionIfMissing(cmd, _localhostTldOption),
ApplyEmptyAppHostTemplateAsync,
runtime: TemplateRuntime.Cli,
languageId: KnownLanguageId.Python,
isEmpty: true),

new CallbackTemplate(
KnownTemplateId.GoEmptyAppHost,
"Empty (Go AppHost)",
Expand Down
5 changes: 5 additions & 0 deletions src/Aspire.Cli/Templating/KnownTemplateId.cs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ internal static class KnownTemplateId
/// </summary>
public const string JavaEmptyAppHost = "aspire-java-empty";

/// <summary>
/// The template ID for the CLI Python empty AppHost template.
/// </summary>
public const string PythonEmptyAppHost = "aspire-py-empty";

/// <summary>
/// The template ID for the Python starter template.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ internal sealed class PythonLanguageSupport : ILanguageSupport
/// <param name="request">The scaffold request containing project details such as the project name and an optional port seed.</param>
/// <returns>
/// A dictionary mapping relative file paths to their contents. The generated files include
/// <c>apphost.py</c>, <c>pylock.toml</c>, and <c>apphost.run.json</c>.
/// <c>.gitignore</c>, <c>apphost.py</c>, <c>pylock.toml</c>, and <c>apphost.run.json</c>.
/// </returns>
/// <remarks>
/// The <c>apphost.run.json</c> file is generated with randomly assigned port numbers unless
Expand All @@ -65,6 +65,14 @@ public Dictionary<string, string> Scaffold(ScaffoldRequest request)
{
var files = new Dictionary<string, string>();

files[".gitignore"] = """
.venv/
__pycache__/
*.pyc
.modules/
.aspire/
""";

// Create apphost.py
files["apphost.py"] = """
# Aspire Python AppHost
Expand Down
12 changes: 12 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -672,6 +672,18 @@ internal static async Task EnableExperimentalJavaSupportAsync(
await auto.WaitForSuccessPromptAsync(counter);
}

/// <summary>
/// Enables experimental Python polyglot support for CLI tests.
/// </summary>
internal static async Task EnableExperimentalPythonSupportAsync(
this Hex1bTerminalAutomator auto,
SequenceCounter counter)
{
await auto.TypeAsync("aspire config set features:experimentalPolyglot:python true --global --non-interactive");
await auto.EnterAsync();
await auto.WaitForSuccessPromptAsync(counter);
}

/// <summary>
/// Installs a specific GA version of the Aspire CLI using the install script.
/// </summary>
Expand Down
63 changes: 63 additions & 0 deletions tests/Aspire.Cli.EndToEnd.Tests/PythonEmptyAppHostTemplateTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Aspire.Cli.EndToEnd.Tests.Helpers;
using Aspire.Cli.Tests.Utils;
using Hex1b.Automation;
using Xunit;

namespace Aspire.Cli.EndToEnd.Tests;

/// <summary>
/// End-to-end tests for the Python empty AppHost template (aspire-py-empty).
/// Validates that aspire new exposes the dedicated "Empty (Python AppHost)"
/// top-level entry when experimental Python polyglot support is enabled and
/// scaffolds a working Python AppHost project.
/// </summary>
public sealed class PythonEmptyAppHostTemplateTests(ITestOutputHelper output)
{
[Fact]
[CaptureWorkspaceOnFailure]
public async Task CreateAndScaffoldPythonEmptyAppHostProject()
{
var repoRoot = CliE2ETestHelpers.GetRepoRoot();
var strategy = CliInstallStrategy.Detect(output.WriteLine);
var workspace = TemporaryWorkspace.Create(output);

using var terminal = CliE2ETestHelpers.CreateDockerTestTerminal(repoRoot, strategy, output, mountDockerSocket: true, workspace: workspace);

var pendingRun = terminal.RunAsync(TestContext.Current.CancellationToken);

var counter = new SequenceCounter();
var auto = new Hex1bTerminalAutomator(terminal, defaultTimeout: TimeSpan.FromSeconds(500));

await auto.PrepareDockerEnvironmentAsync(counter, workspace);
await auto.InstallAspireCliAsync(strategy, counter);
await auto.EnableExperimentalPythonSupportAsync(counter);

// Drives the interactive `aspire new` flow with the new top-level
// "Empty (Python AppHost)" template entry. AspireNewAsync asserts the
// highlighted "> Empty (Python AppHost)" selection appears before
// confirming, which is the primary behavior under test for #16662.
await auto.AspireNewAsync("PythonEmptyApp", counter, template: AspireTemplate.PythonEmptyAppHost);

// Verify the scaffolder produces the expected .gitignore (parity with
// Java/TypeScript empty AppHost scaffolds).
GitIgnoreAssertions.AssertContainsEntry(
Path.Combine(workspace.WorkspaceRoot.FullName, "PythonEmptyApp"),
".aspire/");

// Note: aspire start/stop coverage for the Python empty AppHost is
// intentionally omitted here. Python AppHost cold-start (microvenv
// creation + dependency install from PyPI) can exceed the CLI's
// hard-coded 120s "wait for AppHost to start" timeout in resource-
// constrained CI runners. That is a separate product concern; this
// test focuses on the new selection-prompt + scaffolding behavior
// introduced for #16662.

await auto.TypeAsync("exit");
await auto.EnterAsync();

await pendingRun;
}
}
32 changes: 32 additions & 0 deletions tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,38 @@ public void NewCommandWithPolyglotEnabled_ExposesTemplateSubcommands()
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.JavaEmptyAppHost && subcommand.Description == "Empty (Java AppHost)");
}

[Fact]
public void NewCommandWithPythonPolyglotEnabled_ExposesPythonEmptyAppHostSubcommand()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CreateServiceCollection(workspace, options =>
{
options.FeatureFlagsFactory = _ =>
{
var features = new TestFeatures();
features.SetFeature(KnownFeatures.ExperimentalPolyglotPython, true);
return features;
};
});
using var provider = services.BuildServiceProvider();

var command = provider.GetRequiredService<NewCommand>();
Assert.NotEmpty(command.Subcommands);
Assert.Contains(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.PythonEmptyAppHost && subcommand.Description == "Empty (Python AppHost)");
}

[Fact]
public void NewCommandWithPythonPolyglotDisabled_DoesNotExposePythonEmptyAppHostSubcommand()
{
using var workspace = TemporaryWorkspace.Create(outputHelper);
var services = CreateServiceCollection(workspace);
using var provider = services.BuildServiceProvider();

var command = provider.GetRequiredService<NewCommand>();
Assert.NotEmpty(command.Subcommands);
Assert.DoesNotContain(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.PythonEmptyAppHost);
}

[Fact]
public void NewCommandWithPolyglotDisabled_ExposesTemplateSubcommands()
{
Expand Down
9 changes: 9 additions & 0 deletions tests/Shared/Hex1bAutomatorTestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -597,6 +597,15 @@ await auto.WaitUntilAsync(
await auto.EnterAsync();
break;

case AspireTemplate.PythonEmptyAppHost:
await auto.TypeAsync("Empty (Python AppHost)");
await auto.WaitUntilAsync(
s => new CellPatternSearcher().Find("> Empty (Python AppHost)").Search(s).Count > 0,
timeout: TimeSpan.FromSeconds(5),
description: "Python Empty AppHost template selected");
await auto.EnterAsync();
break;

default:
throw new ArgumentOutOfRangeException(nameof(template), template, $"Unsupported template: {template}");
}
Expand Down
14 changes: 14 additions & 0 deletions tests/Shared/Hex1bTestHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ internal enum AspireTemplate
/// Prompts: template, project name, output path, URLs. No Redis or test project prompt.
/// </summary>
JavaEmptyAppHost,

/// <summary>
/// Empty (Python AppHost) — visible only when experimental Python support is enabled.
/// Prompts: template, project name, output path, URLs. No Redis or test project prompt.
/// </summary>
PythonEmptyAppHost,
}

/// <summary>
Expand Down Expand Up @@ -470,6 +476,14 @@ internal static Hex1bTerminalInputSequenceBuilder AspireNew(
.WaitUntil(s => javaEmptyAppHostSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5))
.Enter();
break;

case AspireTemplate.PythonEmptyAppHost:
var pythonEmptyAppHostSelected = new CellPatternSearcher()
.Find("> Empty (Python AppHost)");
builder.Type("Empty (Python AppHost)")
.WaitUntil(s => pythonEmptyAppHostSelected.Search(s).Count > 0, TimeSpan.FromSeconds(5))
.Enter();
break;
}

// Step 3: Enter project name
Expand Down
Loading