-
Notifications
You must be signed in to change notification settings - Fork 850
Move Python starter template to TypeScript AppHost and CLI template factory #15574
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
davidfowl
wants to merge
1
commit into
main
Choose a base branch
from
davidfowl/python-template-ts
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+3,136
−638
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
160 changes: 160 additions & 0 deletions
160
src/Aspire.Cli/Templating/CliTemplateFactory.PythonStarterTemplate.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,160 @@ | ||
| // 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.Configuration; | ||
| using Aspire.Cli.Interaction; | ||
| using Aspire.Cli.Projects; | ||
| using Aspire.Cli.Resources; | ||
| using Microsoft.Extensions.Logging; | ||
| using Spectre.Console; | ||
|
|
||
| namespace Aspire.Cli.Templating; | ||
|
|
||
| internal sealed partial class CliTemplateFactory | ||
| { | ||
| private async Task<TemplateResult> ApplyPythonStarterTemplateAsync(CallbackTemplate _, TemplateInputs inputs, System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken) | ||
| { | ||
| var projectName = inputs.Name; | ||
| if (string.IsNullOrWhiteSpace(projectName)) | ||
| { | ||
| var defaultName = _executionContext.WorkingDirectory.Name; | ||
| projectName = await _prompter.PromptForProjectNameAsync(defaultName, cancellationToken); | ||
| } | ||
|
|
||
| if (string.IsNullOrWhiteSpace(inputs.Version)) | ||
| { | ||
| _interactionService.DisplayError("Unable to determine Aspire version for the Python starter template."); | ||
| return new TemplateResult(ExitCodeConstants.InvalidCommand); | ||
| } | ||
|
|
||
| var aspireVersion = inputs.Version; | ||
| var outputPath = inputs.Output; | ||
| if (string.IsNullOrWhiteSpace(outputPath)) | ||
| { | ||
| var defaultOutputPath = $"./{projectName}"; | ||
| outputPath = await _prompter.PromptForOutputPath(defaultOutputPath, cancellationToken); | ||
| } | ||
| outputPath = Path.GetFullPath(outputPath, _executionContext.WorkingDirectory.FullName); | ||
|
|
||
| _logger.LogDebug("Applying Python starter template. ProjectName: {ProjectName}, OutputPath: {OutputPath}, AspireVersion: {AspireVersion}.", projectName, outputPath, aspireVersion); | ||
|
|
||
| var useLocalhostTld = await ResolveUseLocalhostTldAsync(parseResult, cancellationToken); | ||
| var useRedisCache = await ResolveUseRedisCacheAsync(parseResult, cancellationToken); | ||
|
|
||
| TemplateResult templateResult; | ||
| try | ||
| { | ||
| if (!Directory.Exists(outputPath)) | ||
| { | ||
| Directory.CreateDirectory(outputPath); | ||
| } | ||
|
|
||
| templateResult = await _interactionService.ShowStatusAsync( | ||
| TemplatingStrings.CreatingNewProject, | ||
| async () => | ||
| { | ||
| var projectNameLower = projectName.ToLowerInvariant(); | ||
|
|
||
| var ports = GenerateRandomPorts(); | ||
| var hostName = useLocalhostTld ? $"{projectNameLower}.dev.localhost" : "localhost"; | ||
| var conditions = new Dictionary<string, bool> | ||
| { | ||
| ["redis"] = useRedisCache, | ||
| ["no-redis"] = !useRedisCache, | ||
| }; | ||
| string ApplyAllTokens(string content) => ConditionalBlockProcessor.Process( | ||
| ApplyTokens(content, projectName, projectNameLower, aspireVersion, ports, hostName), | ||
| conditions); | ||
| _logger.LogDebug("Copying embedded Python starter template files to '{OutputPath}'.", outputPath); | ||
| await CopyTemplateTreeToDiskAsync("py-starter", outputPath, ApplyAllTokens, cancellationToken); | ||
|
|
||
| if (useRedisCache) | ||
| { | ||
| AddRedisPackageToConfig(outputPath, aspireVersion); | ||
| } | ||
|
|
||
| // Write channel to settings.json before restore so package resolution uses the selected channel. | ||
| if (!string.IsNullOrEmpty(inputs.Channel)) | ||
| { | ||
| var config = AspireJsonConfiguration.Load(outputPath); | ||
| if (config is not null) | ||
| { | ||
| config.Channel = inputs.Channel; | ||
| config.Save(outputPath); | ||
| } | ||
| } | ||
|
|
||
| var appHostProject = _projectFactory.TryGetProject(new FileInfo(Path.Combine(outputPath, "apphost.ts"))); | ||
| if (appHostProject is not IGuestAppHostSdkGenerator guestProject) | ||
| { | ||
| _interactionService.DisplayError("Automatic 'aspire restore' is unavailable for the new Python starter project because no TypeScript AppHost SDK generator was found."); | ||
| return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath); | ||
| } | ||
|
|
||
| _logger.LogDebug("Generating SDK code for Python starter in '{OutputPath}'.", outputPath); | ||
| var restoreSucceeded = await guestProject.BuildAndGenerateSdkAsync(new DirectoryInfo(outputPath), cancellationToken); | ||
| if (!restoreSucceeded) | ||
| { | ||
| _interactionService.DisplayError("Automatic 'aspire restore' failed for the new Python starter project. Run 'aspire restore' in the project directory for more details."); | ||
| return new TemplateResult(ExitCodeConstants.FailedToBuildArtifacts, outputPath); | ||
| } | ||
|
|
||
| return new TemplateResult(ExitCodeConstants.Success, outputPath); | ||
| }, emoji: KnownEmojis.Rocket); | ||
|
|
||
| if (templateResult.ExitCode != ExitCodeConstants.Success) | ||
| { | ||
| return templateResult; | ||
| } | ||
| } | ||
| catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) | ||
| { | ||
| _interactionService.DisplayError($"Failed to create project files: {ex.Message}"); | ||
| return new TemplateResult(ExitCodeConstants.FailedToCreateNewProject); | ||
| } | ||
|
|
||
| _interactionService.DisplaySuccess($"Created Python starter project at {outputPath.EscapeMarkup()}"); | ||
| DisplayPostCreationInstructions(outputPath); | ||
|
|
||
| return templateResult; | ||
| } | ||
|
|
||
| private async Task<bool> ResolveUseRedisCacheAsync(System.CommandLine.ParseResult parseResult, CancellationToken cancellationToken) | ||
| { | ||
| var redisCacheOptionSpecified = parseResult.Tokens.Any(token => | ||
| string.Equals(token.Value, "--use-redis-cache", StringComparisons.CliInputOrOutput)); | ||
| var useRedisCache = parseResult.GetValue(_useRedisCacheOption); | ||
| if (!redisCacheOptionSpecified) | ||
| { | ||
| if (!_hostEnvironment.SupportsInteractiveInput) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| useRedisCache = await _interactionService.PromptForSelectionAsync( | ||
| TemplatingStrings.UseRedisCache_Prompt, | ||
| [TemplatingStrings.Yes, TemplatingStrings.No], | ||
| choice => choice, | ||
| cancellationToken) switch | ||
| { | ||
| var choice when string.Equals(choice, TemplatingStrings.Yes, StringComparisons.CliInputOrOutput) => true, | ||
| var choice when string.Equals(choice, TemplatingStrings.No, StringComparisons.CliInputOrOutput) => false, | ||
| _ => throw new InvalidOperationException(TemplatingStrings.UseRedisCache_UnexpectedChoice) | ||
davidfowl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
| } | ||
|
|
||
| if (useRedisCache ?? false) | ||
| { | ||
| _interactionService.DisplayMessage(KnownEmojis.CheckMark, TemplatingStrings.UseRedisCache_UsingRedisCache); | ||
| } | ||
|
|
||
| return useRedisCache ?? false; | ||
| } | ||
|
|
||
| private static void AddRedisPackageToConfig(string outputPath, string aspireVersion) | ||
| { | ||
| var config = AspireConfigFile.LoadOrCreate(outputPath); | ||
| config.AddOrUpdatePackage("Aspire.Hosting.Redis", aspireVersion); | ||
| config.Save(outputPath); | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| // Licensed to the .NET Foundation under one or more agreements. | ||
| // The .NET Foundation licenses this file to you under the MIT license. | ||
|
|
||
| using System.Diagnostics; | ||
| using System.Text.RegularExpressions; | ||
|
|
||
| namespace Aspire.Cli.Templating; | ||
|
|
||
| /// <summary> | ||
| /// Processes conditional blocks in template content. Blocks are delimited by | ||
| /// marker lines of the form <c>{{#name}}</c> / <c>{{/name}}</c>. When a block | ||
| /// is included, the marker lines are stripped and the inner content is kept; | ||
| /// when excluded, the marker lines and their content are removed entirely. | ||
| /// Marker lines may contain leading comment characters (e.g. <c>// {{#name}}</c> | ||
| /// or <c># {{#name}}</c>) — the entire line is always removed. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// Blocks must not overlap or nest across different condition names. Each condition | ||
| /// is processed independently in enumeration order. Overlapping blocks produce | ||
| /// undefined behavior. | ||
| /// </remarks> | ||
| internal static partial class ConditionalBlockProcessor | ||
| { | ||
| /// <summary> | ||
| /// Processes all conditional blocks for the given set of conditions. Each entry | ||
| /// in <paramref name="conditions"/> maps a block name to whether it should be included. | ||
| /// </summary> | ||
| /// <param name="content">The template content to process.</param> | ||
| /// <param name="conditions">A set of block-name to include/exclude mappings.</param> | ||
| /// <returns>The processed content with conditional blocks resolved.</returns> | ||
| internal static string Process(string content, IReadOnlyDictionary<string, bool> conditions) | ||
| { | ||
| foreach (var (blockName, include) in conditions) | ||
| { | ||
| content = ProcessBlock(content, blockName, include); | ||
| } | ||
|
|
||
| Debug.Assert( | ||
| !LeftoverMarkerPattern().IsMatch(content), | ||
| $"Template content contains unprocessed conditional markers. Ensure all block names are included in the conditions dictionary."); | ||
|
|
||
| return content; | ||
davidfowl marked this conversation as resolved.
Show resolved
Hide resolved
davidfowl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| [GeneratedRegex(@"\{\{[#/][a-zA-Z][\w-]*\}\}")] | ||
| private static partial Regex LeftoverMarkerPattern(); | ||
|
|
||
| /// <summary> | ||
| /// Processes all occurrences of a single conditional block in the content. | ||
| /// </summary> | ||
| /// <param name="content">The template content to process.</param> | ||
| /// <param name="blockName">The name of the conditional block (e.g. <c>redis</c>).</param> | ||
| /// <param name="include"> | ||
| /// When <see langword="true"/>, the block content is kept and only the marker lines | ||
| /// are removed. When <see langword="false"/>, the entire block (markers and content) is removed. | ||
| /// </param> | ||
| /// <returns>The processed content.</returns> | ||
| internal static string ProcessBlock(string content, string blockName, bool include) | ||
| { | ||
| var startPattern = $"{{{{#{blockName}}}}}"; | ||
| var endPattern = $"{{{{/{blockName}}}}}"; | ||
|
|
||
| while (true) | ||
| { | ||
| var startIdx = content.IndexOf(startPattern, StringComparison.Ordinal); | ||
| if (startIdx < 0) | ||
| { | ||
| break; | ||
davidfowl marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| var endIdx = content.IndexOf(endPattern, startIdx, StringComparison.Ordinal); | ||
| if (endIdx < 0) | ||
| { | ||
| throw new InvalidOperationException( | ||
| $"Template contains opening marker '{{{{#{blockName}}}}}' without a matching closing marker '{{{{/{blockName}}}}}'."); | ||
| } | ||
|
|
||
| // Find the full start marker line (including leading whitespace/comments and trailing newline). | ||
| var startLineBegin = content.LastIndexOf('\n', startIdx); | ||
| startLineBegin = startLineBegin < 0 ? 0 : startLineBegin + 1; | ||
| var startLineEnd = content.IndexOf('\n', startIdx); | ||
| startLineEnd = startLineEnd < 0 ? content.Length : startLineEnd + 1; | ||
|
|
||
| // Find the full end marker line. | ||
| var endLineBegin = content.LastIndexOf('\n', endIdx); | ||
| endLineBegin = endLineBegin < 0 ? 0 : endLineBegin + 1; | ||
| var endLineEnd = content.IndexOf('\n', endIdx); | ||
| endLineEnd = endLineEnd < 0 ? content.Length : endLineEnd + 1; | ||
|
|
||
| if (include) | ||
| { | ||
| // Keep the block content but remove the marker lines. | ||
| var blockContent = content[startLineEnd..endLineBegin]; | ||
| content = string.Concat(content.AsSpan(0, startLineBegin), blockContent, content.AsSpan(endLineEnd)); | ||
| } | ||
| else | ||
| { | ||
| // Remove everything from start marker line to end marker line (inclusive). | ||
| content = string.Concat(content.AsSpan(0, startLineBegin), content.AsSpan(endLineEnd)); | ||
| } | ||
| } | ||
|
|
||
| return content; | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.