diff --git a/src/Aspire.Cli/Templating/CliTemplateFactory.cs b/src/Aspire.Cli/Templating/CliTemplateFactory.cs index 0415b501365..524d2246d74 100644 --- a/src/Aspire.Cli/Templating/CliTemplateFactory.cs +++ b/src/Aspire.Cli/Templating/CliTemplateFactory.cs @@ -138,6 +138,16 @@ private IEnumerable 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)", diff --git a/src/Aspire.Cli/Templating/KnownTemplateId.cs b/src/Aspire.Cli/Templating/KnownTemplateId.cs index f7eef31ab47..5f62caebe0e 100644 --- a/src/Aspire.Cli/Templating/KnownTemplateId.cs +++ b/src/Aspire.Cli/Templating/KnownTemplateId.cs @@ -33,6 +33,11 @@ internal static class KnownTemplateId /// public const string JavaEmptyAppHost = "aspire-java-empty"; + /// + /// The template ID for the CLI Python empty AppHost template. + /// + public const string PythonEmptyAppHost = "aspire-py-empty"; + /// /// The template ID for the Python starter template. /// diff --git a/src/Aspire.Hosting.CodeGeneration.Python/PythonLanguageSupport.cs b/src/Aspire.Hosting.CodeGeneration.Python/PythonLanguageSupport.cs index 18cc8a9d09e..82c93a0d76d 100644 --- a/src/Aspire.Hosting.CodeGeneration.Python/PythonLanguageSupport.cs +++ b/src/Aspire.Hosting.CodeGeneration.Python/PythonLanguageSupport.cs @@ -55,7 +55,7 @@ internal sealed class PythonLanguageSupport : ILanguageSupport /// The scaffold request containing project details such as the project name and an optional port seed. /// /// A dictionary mapping relative file paths to their contents. The generated files include - /// apphost.py, pylock.toml, and apphost.run.json. + /// .gitignore, apphost.py, pylock.toml, and apphost.run.json. /// /// /// The apphost.run.json file is generated with randomly assigned port numbers unless @@ -65,6 +65,14 @@ public Dictionary Scaffold(ScaffoldRequest request) { var files = new Dictionary(); + files[".gitignore"] = """ + .venv/ + __pycache__/ + *.pyc + .modules/ + .aspire/ + """; + // Create apphost.py files["apphost.py"] = """ # Aspire Python AppHost diff --git a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs index 9f20237a613..5d5b822bd38 100644 --- a/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs +++ b/tests/Aspire.Cli.EndToEnd.Tests/Helpers/CliE2EAutomatorHelpers.cs @@ -672,6 +672,18 @@ internal static async Task EnableExperimentalJavaSupportAsync( await auto.WaitForSuccessPromptAsync(counter); } + /// + /// Enables experimental Python polyglot support for CLI tests. + /// + 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); + } + /// /// Installs a specific GA version of the Aspire CLI using the install script. /// diff --git a/tests/Aspire.Cli.EndToEnd.Tests/PythonEmptyAppHostTemplateTests.cs b/tests/Aspire.Cli.EndToEnd.Tests/PythonEmptyAppHostTemplateTests.cs new file mode 100644 index 00000000000..76c833fb27b --- /dev/null +++ b/tests/Aspire.Cli.EndToEnd.Tests/PythonEmptyAppHostTemplateTests.cs @@ -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; + +/// +/// 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. +/// +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; + } +} diff --git a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs index 715c95d2ee1..ac4673ab1c9 100644 --- a/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs +++ b/tests/Aspire.Cli.Tests/Commands/NewCommandTests.cs @@ -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(); + 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(); + Assert.NotEmpty(command.Subcommands); + Assert.DoesNotContain(command.Subcommands, subcommand => subcommand.Name == KnownTemplateId.PythonEmptyAppHost); + } + [Fact] public void NewCommandWithPolyglotDisabled_ExposesTemplateSubcommands() { diff --git a/tests/Shared/Hex1bAutomatorTestHelpers.cs b/tests/Shared/Hex1bAutomatorTestHelpers.cs index 340a927f4a4..a2d5755051c 100644 --- a/tests/Shared/Hex1bAutomatorTestHelpers.cs +++ b/tests/Shared/Hex1bAutomatorTestHelpers.cs @@ -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}"); } diff --git a/tests/Shared/Hex1bTestHelpers.cs b/tests/Shared/Hex1bTestHelpers.cs index c30a5e02a35..56d45ebfcb7 100644 --- a/tests/Shared/Hex1bTestHelpers.cs +++ b/tests/Shared/Hex1bTestHelpers.cs @@ -67,6 +67,12 @@ internal enum AspireTemplate /// Prompts: template, project name, output path, URLs. No Redis or test project prompt. /// JavaEmptyAppHost, + + /// + /// Empty (Python AppHost) — visible only when experimental Python support is enabled. + /// Prompts: template, project name, output path, URLs. No Redis or test project prompt. + /// + PythonEmptyAppHost, } /// @@ -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