Skip to content

Commit 8450db4

Browse files
davidfowlCopilot
andcommitted
Move Python starter template to TypeScript AppHost and CLI template factory
- Migrate py-starter from DotNetTemplateFactory (C# AppHost) to CliTemplateFactory (TypeScript AppHost) - Add ConditionalBlockProcessor for template conditional blocks (redis support) - Add CaptureWorkspaceOnFailure attribute for CI workspace capture on test failure - Remove old dotnet new template from Aspire.ProjectTemplates - E2E test verifies template creation and SDK generation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 0c2f6b6 commit 8450db4

64 files changed

Lines changed: 3095 additions & 629 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/skills/ci-test-failures/SKILL.md

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,11 +214,23 @@ artifact_0_TestName_os/
214214
├── testresults/
215215
│ ├── TestName_net10.0_timestamp.trx # Test results XML
216216
│ ├── Aspire.*.Tests_*.log # Console output
217-
│ └── recordings/ # Asciinema recordings (CLI E2E tests)
217+
│ ├── recordings/ # Asciinema recordings (CLI E2E tests)
218+
│ └── workspaces/ # Captured project workspaces (CLI E2E tests)
219+
│ └── TestClassName.MethodName/ # Full generated project for failed tests
220+
│ ├── apphost.ts
221+
│ ├── aspire.config.json
222+
│ ├── .modules/ # Generated SDK (aspire.js) - key for debugging
223+
│ └── ...
218224
├── *.crash.dmp # Crash dump (if test crashed)
219225
└── test.binlog # MSBuild binary log
220226
```
221227
228+
### CLI E2E Workspace Capture
229+
230+
CLI E2E tests annotated with `[CaptureWorkspaceOnFailure]` automatically capture the full generated project workspace when a test fails. This includes the generated SDK (`.modules/aspire.js`), template output, and config files — critical for debugging template generation or `aspire run` failures.
231+
232+
Look in `testresults/workspaces/{TestClassName.MethodName}/` inside the downloaded artifact.
233+
222234
## Parsing .trx Files
223235
224236
```powershell

.github/skills/cli-e2e-testing/SKILL.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -569,9 +569,31 @@ testresults/
569569
├── Aspire.Cli.EndToEnd.Tests_*.log # Console output log
570570
├── *.crash.dmp # Crash dump (if test crashed)
571571
├── test.binlog # MSBuild binary log
572-
└── recordings/
573-
├── CreateAndRunAspireStarterProject.cast # Asciinema recording
574-
└── ...
572+
├── recordings/
573+
│ ├── CreateAndRunAspireStarterProject.cast # Asciinema recording
574+
│ └── ...
575+
└── workspaces/ # Captured project workspaces (on failure)
576+
└── TestClassName.MethodName/ # Full generated project for debugging
577+
├── apphost.ts
578+
├── aspire.config.json
579+
├── .modules/ # Generated SDK - check aspire.js for exports
580+
└── ...
581+
```
582+
583+
### Workspace Capture
584+
585+
Tests annotated with `[CaptureWorkspaceOnFailure]` automatically copy the generated project workspace into the test artifacts when a test fails. This is invaluable for debugging template generation or `aspire run` failures — you can inspect the exact generated files including the SDK output in `.modules/aspire.js`.
586+
587+
To add workspace capture to a new test:
588+
```csharp
589+
[Fact]
590+
[CaptureWorkspaceOnFailure]
591+
public async Task MyTemplateTest()
592+
{
593+
var workspace = TemporaryWorkspace.Create(output);
594+
// ... test code — workspace is automatically registered for capture ...
595+
}
596+
```
575597
```
576598
577599
### One-Liner: Download Latest Recording

src/Aspire.Cli/Aspire.Cli.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@
212212
<EmbeddedResource Include="Templating\Templates\ts-starter\**\*" LogicalName="ts-starter.%(RecursiveDir)%(Filename)%(Extension)">
213213
<WithCulture>false</WithCulture>
214214
</EmbeddedResource>
215+
<EmbeddedResource Include="Templating\Templates\py-starter\**\*" LogicalName="py-starter.%(RecursiveDir)%(Filename)%(Extension)">
216+
<WithCulture>false</WithCulture>
217+
</EmbeddedResource>
215218
</ItemGroup>
216219

217220
<ItemGroup>
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using Aspire.Cli.Configuration;
5+
using Aspire.Cli.Interaction;
6+
using Aspire.Cli.Projects;
7+
using Aspire.Cli.Resources;
8+
using Microsoft.Extensions.Logging;
9+
using Spectre.Console;
10+
11+
namespace Aspire.Cli.Templating;
12+
13+
internal sealed partial class CliTemplateFactory
14+
{
15+
private async Task<TemplateResult> ApplyPythonStarterTemplateAsync(CallbackTemplate _, TemplateInputs inputs, System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken)
16+
{
17+
var projectName = inputs.Name;
18+
if (string.IsNullOrWhiteSpace(projectName))
19+
{
20+
var defaultName = _executionContext.WorkingDirectory.Name;
21+
projectName = await _prompter.PromptForProjectNameAsync(defaultName, cancellationToken);
22+
}
23+
24+
if (string.IsNullOrWhiteSpace(inputs.Version))
25+
{
26+
_interactionService.DisplayError("Unable to determine Aspire version for the Python starter template.");
27+
return new TemplateResult(ExitCodeConstants.InvalidCommand);
28+
}
29+
30+
var aspireVersion = inputs.Version;
31+
var outputPath = inputs.Output;
32+
if (string.IsNullOrWhiteSpace(outputPath))
33+
{
34+
var defaultOutputPath = $"./{projectName}";
35+
outputPath = await _prompter.PromptForOutputPath(defaultOutputPath, cancellationToken);
36+
}
37+
outputPath = Path.GetFullPath(outputPath, _executionContext.WorkingDirectory.FullName);
38+
39+
_logger.LogDebug("Applying Python starter template. ProjectName: {ProjectName}, OutputPath: {OutputPath}, AspireVersion: {AspireVersion}.", projectName, outputPath, aspireVersion);
40+
41+
var useLocalhostTld = await ResolveUseLocalhostTldAsync(parseResult, cancellationToken);
42+
var useRedisCache = await ResolveUseRedisCacheAsync(parseResult, cancellationToken);
43+
44+
TemplateResult templateResult;
45+
try
46+
{
47+
if (!Directory.Exists(outputPath))
48+
{
49+
Directory.CreateDirectory(outputPath);
50+
}
51+
52+
templateResult = await _interactionService.ShowStatusAsync(
53+
TemplatingStrings.CreatingNewProject,
54+
async () =>
55+
{
56+
var projectNameLower = projectName.ToLowerInvariant();
57+
58+
var ports = GenerateRandomPorts();
59+
var hostName = useLocalhostTld ? $"{projectNameLower}.dev.localhost" : "localhost";
60+
var conditions = new Dictionary<string, bool>
61+
{
62+
["redis"] = useRedisCache,
63+
["no-redis"] = !useRedisCache,
64+
};
65+
string ApplyAllTokens(string content) => ConditionalBlockProcessor.Process(
66+
ApplyTokens(content, projectName, projectNameLower, aspireVersion, ports, hostName),
67+
conditions);
68+
_logger.LogDebug("Copying embedded Python starter template files to '{OutputPath}'.", outputPath);
69+
await CopyTemplateTreeToDiskAsync("py-starter", outputPath, ApplyAllTokens, cancellationToken);
70+
71+
if (useRedisCache)
72+
{
73+
AddRedisPackageToConfig(outputPath, aspireVersion);
74+
}
75+
76+
// Write channel to settings.json before restore so package resolution uses the selected channel.
77+
if (!string.IsNullOrEmpty(inputs.Channel))
78+
{
79+
var config = AspireJsonConfiguration.Load(outputPath);
80+
if (config is not null)
81+
{
82+
config.Channel = inputs.Channel;
83+
config.Save(outputPath);
84+
}
85+
}
86+
87+
var appHostProject = _projectFactory.TryGetProject(new FileInfo(Path.Combine(outputPath, "apphost.ts")));
88+
if (appHostProject is not IGuestAppHostSdkGenerator guestProject)
89+
{
90+
_interactionService.DisplayError("Automatic 'aspire restore' is unavailable for the new Python starter project because no TypeScript AppHost SDK generator was found.");
91+
return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath);
92+
}
93+
94+
_logger.LogDebug("Generating SDK code for Python starter in '{OutputPath}'.", outputPath);
95+
var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken);
96+
if (!restoreSucceeded)
97+
{
98+
_interactionService.DisplayError("Automatic 'aspire restore' failed for the new Python starter project. Run 'aspire restore' in the project directory for more details.");
99+
return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath);
100+
}
101+
102+
return new TemplateResult(ExitCodeConstants.Success, outputPath);
103+
}, emoji: KnownEmojis.Rocket);
104+
105+
if (templateResult.ExitCode != ExitCodeConstants.Success)
106+
{
107+
return templateResult;
108+
}
109+
}
110+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
111+
{
112+
_interactionService.DisplayError($"Failed to create project files: {ex.Message}");
113+
return new TemplateResult(ExitCodeConstants.FailedToCreateNewProject);
114+
}
115+
116+
_interactionService.DisplaySuccess($"Created Python starter project at {outputPath.EscapeMarkup()}");
117+
DisplayPostCreationInstructions(outputPath);
118+
119+
return templateResult;
120+
}
121+
122+
private async Task<bool> ResolveUseRedisCacheAsync(System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken)
123+
{
124+
var redisCacheOptionSpecified = parseResult.Tokens.Any(token =>
125+
string.Equals(token.Value, "--use-redis-cache", StringComparisons.CliInputOrOutput));
126+
var useRedisCache = parseResult.GetValue(_useRedisCacheOption);
127+
if (!redisCacheOptionSpecified)
128+
{
129+
if (!_hostEnvironment.SupportsInteractiveInput)
130+
{
131+
return false;
132+
}
133+
134+
useRedisCache = await _interactionService.PromptForSelectionAsync(
135+
TemplatingStrings.UseRedisCache_Prompt,
136+
[TemplatingStrings.Yes, TemplatingStrings.No],
137+
choice => choice,
138+
cancellationToken) switch
139+
{
140+
var choice when string.Equals(choice, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput) => true,
141+
var choice when string.Equals(choice, TemplatingStrings.No, StringComparisons.CliInputOrOutput) => false,
142+
_ => throw new InvalidOperationException(TemplatingStrings.UseRedisCache_UnexpectedChoice)
143+
};
144+
}
145+
146+
if (useRedisCache ?? false)
147+
{
148+
_interactionService.DisplayMessage(KnownEmojis.CheckMark, TemplatingStrings.UseRedisCache_UsingRedisCache);
149+
}
150+
151+
return useRedisCache ?? false;
152+
}
153+
154+
private static void AddRedisPackageToConfig(string outputPath, string aspireVersion)
155+
{
156+
var config = AspireConfigFile.LoadOrCreate(outputPath);
157+
config.AddOrUpdatePackage("Aspire.Hosting.Redis", aspireVersion);
158+
config.Save(outputPath);
159+
}
160+
}

src/Aspire.Cli/Templating/CliTemplateFactory.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,11 @@ internal sealed partial class CliTemplateFactory : ITemplateFactory
3737
Description = TemplatingStrings.UseLocalhostTld_Description
3838
};
3939

40+
private readonly Option<bool?> _useRedisCacheOption = new("--use-redis-cache")
41+
{
42+
Description = "Use Redis for output caching"
43+
};
44+
4045
private readonly ILanguageDiscovery _languageDiscovery;
4146
private readonly IAppHostProjectFactory _projectFactory;
4247
private readonly IScaffoldingService _scaffoldingService;
@@ -130,7 +135,20 @@ private IEnumerable<ITemplate> GetTemplateDefinitions()
130135
ApplyEmptyAppHostTemplateAsync,
131136
runtime: TemplateRuntime.Cli,
132137
languageId: KnownLanguageId.Java,
133-
isEmpty: true)
138+
isEmpty: true),
139+
140+
new CallbackTemplate(
141+
KnownTemplateId.PythonStarter,
142+
"Starter App (FastAPI/React)",
143+
projectName => $"./{projectName}",
144+
cmd =>
145+
{
146+
AddOptionIfMissing(cmd, _localhostTldOption);
147+
AddOptionIfMissing(cmd, _useRedisCacheOption);
148+
},
149+
ApplyPythonStarterTemplateAsync,
150+
runtime: TemplateRuntime.Cli,
151+
languageId: KnownLanguageId.TypeScript)
134152
];
135153

136154
return templates.Where(IsTemplateAvailable);
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Aspire.Cli.Templating;
5+
6+
/// <summary>
7+
/// Processes conditional blocks in template content. Blocks are delimited by
8+
/// marker lines of the form <c>{{#name}}</c> / <c>{{/name}}</c>. When a block
9+
/// is included, the marker lines are stripped and the inner content is kept;
10+
/// when excluded, the marker lines and their content are removed entirely.
11+
/// Marker lines may contain leading comment characters (e.g. <c>// {{#name}}</c>
12+
/// or <c># {{#name}}</c>) — the entire line is always removed.
13+
/// </summary>
14+
internal static class ConditionalBlockProcessor
15+
{
16+
/// <summary>
17+
/// Processes all conditional blocks for the given set of conditions. Each entry
18+
/// in <paramref name="conditions"/> maps a block name to whether it should be included.
19+
/// </summary>
20+
/// <param name="content">The template content to process.</param>
21+
/// <param name="conditions">A set of block-name to include/exclude mappings.</param>
22+
/// <returns>The processed content with conditional blocks resolved.</returns>
23+
internal static string Process(string content, IReadOnlyDictionary<string, bool> conditions)
24+
{
25+
foreach (var (blockName, include) in conditions)
26+
{
27+
content = ProcessBlock(content, blockName, include);
28+
}
29+
30+
return content;
31+
}
32+
33+
/// <summary>
34+
/// Processes all occurrences of a single conditional block in the content.
35+
/// </summary>
36+
/// <param name="content">The template content to process.</param>
37+
/// <param name="blockName">The name of the conditional block (e.g. <c>redis</c>).</param>
38+
/// <param name="include">
39+
/// When <see langword="true"/>, the block content is kept and only the marker lines
40+
/// are removed. When <see langword="false"/>, the entire block (markers and content) is removed.
41+
/// </param>
42+
/// <returns>The processed content.</returns>
43+
internal static string ProcessBlock(string content, string blockName, bool include)
44+
{
45+
var startPattern = $"{{{{#{blockName}}}}}";
46+
var endPattern = $"{{{{/{blockName}}}}}";
47+
48+
while (true)
49+
{
50+
var startIdx = content.IndexOf(startPattern, StringComparison.Ordinal);
51+
if (startIdx < 0)
52+
{
53+
break;
54+
}
55+
56+
var endIdx = content.IndexOf(endPattern, startIdx, StringComparison.Ordinal);
57+
if (endIdx < 0)
58+
{
59+
break;
60+
}
61+
62+
// Find the full start marker line (including leading whitespace/comments and trailing newline).
63+
var startLineBegin = content.LastIndexOf('\n', startIdx);
64+
startLineBegin = startLineBegin < 0 ? 0 : startLineBegin + 1;
65+
var startLineEnd = content.IndexOf('\n', startIdx);
66+
startLineEnd = startLineEnd < 0 ? content.Length : startLineEnd + 1;
67+
68+
// Find the full end marker line.
69+
var endLineBegin = content.LastIndexOf('\n', endIdx);
70+
endLineBegin = endLineBegin < 0 ? 0 : endLineBegin + 1;
71+
var endLineEnd = content.IndexOf('\n', endIdx);
72+
endLineEnd = endLineEnd < 0 ? content.Length : endLineEnd + 1;
73+
74+
if (include)
75+
{
76+
// Keep the block content but remove the marker lines.
77+
var blockContent = content[startLineEnd..endLineBegin];
78+
content = string.Concat(content.AsSpan(0, startLineBegin), blockContent, content.AsSpan(endLineEnd));
79+
}
80+
else
81+
{
82+
// Remove everything from start marker line to end marker line (inclusive).
83+
content = string.Concat(content.AsSpan(0, startLineBegin), content.AsSpan(endLineEnd));
84+
}
85+
}
86+
87+
return content;
88+
}
89+
}

src/Aspire.Cli/Templating/DotNetTemplateFactory.cs

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -158,17 +158,6 @@ private IEnumerable<ITemplate> GetTemplatesCore(bool showAllTemplates, bool nonI
158158
languageId: KnownLanguageId.CSharp
159159
);
160160

161-
yield return new CallbackTemplate(
162-
"aspire-py-starter",
163-
TemplatingStrings.AspirePyStarter_Description,
164-
projectName => $"./{projectName}",
165-
ApplyDevLocalhostTldOption,
166-
nonInteractive
167-
? ApplySingleFileTemplateWithNoExtraArgsAsync
168-
: (template, inputs, parseResult, ct) => ApplySingleFileTemplate(template, inputs, parseResult, PromptForExtraAspirePythonStarterOptionsAsync, ct),
169-
languageId: KnownLanguageId.CSharp
170-
);
171-
172161
if (showAllTemplates)
173162
{
174163
yield return new CallbackTemplate(
@@ -290,16 +279,6 @@ private async Task<string[]> PromptForExtraAspireSingleFileOptionsAsync(ParseRes
290279
return extraArgs.ToArray();
291280
}
292281

293-
private async Task<string[]> PromptForExtraAspirePythonStarterOptionsAsync(ParseResult result, CancellationToken cancellationToken)
294-
{
295-
var extraArgs = new List<string>();
296-
297-
await PromptForDevLocalhostTldOptionAsync(result, extraArgs, cancellationToken);
298-
await PromptForRedisCacheOptionAsync(result, extraArgs, cancellationToken);
299-
300-
return extraArgs.ToArray();
301-
}
302-
303282
private async Task<string[]> PromptForExtraAspireJsFrontendStarterOptionsAsync(ParseResult result, CancellationToken cancellationToken)
304283
{
305284
var extraArgs = new List<string>();

src/Aspire.Cli/Templating/KnownTemplateId.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,9 @@ internal static class KnownTemplateId
3232
/// The template ID for the CLI Java empty AppHost template.
3333
/// </summary>
3434
public const string JavaEmptyAppHost = "aspire-java-empty";
35+
36+
/// <summary>
37+
/// The template ID for the Python starter template.
38+
/// </summary>
39+
public const string PythonStarter = "aspire-py-starter";
3540
}

src/Aspire.ProjectTemplates/templates/aspire-py-starter/app/.dockerignore renamed to src/Aspire.Cli/Templating/Templates/py-starter/app/.dockerignore

File renamed without changes.

src/Aspire.ProjectTemplates/templates/aspire-py-starter/app/.python-version renamed to src/Aspire.Cli/Templating/Templates/py-starter/app/.python-version

File renamed without changes.

0 commit comments

Comments
 (0)