From ad4ebc92ca5e4317edd7a08c60b1ada7e9d7f341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B8is=C3=A6ther=20Rasch?= Date: Mon, 31 Mar 2025 10:11:52 +0200 Subject: [PATCH 1/5] Start work on simplifying and improving System.CommandLine.Hosting library From 13f6b8739dd7504897cb12cc021b0a8dd531714a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B8is=C3=A6ther=20Rasch?= Date: Mon, 31 Mar 2025 10:18:36 +0200 Subject: [PATCH 2/5] Remove InvocationLifetime class --- .../HostingAction.cs | 7 +- .../HostingExtensions.cs | 10 -- .../InvocationLifetime.cs | 100 ------------------ .../InvocationLifetimeOptions.cs | 8 -- 4 files changed, 6 insertions(+), 119 deletions(-) delete mode 100644 src/System.CommandLine.Hosting/InvocationLifetime.cs delete mode 100644 src/System.CommandLine.Hosting/InvocationLifetimeOptions.cs diff --git a/src/System.CommandLine.Hosting/HostingAction.cs b/src/System.CommandLine.Hosting/HostingAction.cs index 3518ea4775..117abadc17 100644 --- a/src/System.CommandLine.Hosting/HostingAction.cs +++ b/src/System.CommandLine.Hosting/HostingAction.cs @@ -50,6 +50,12 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio ?? new HostBuilder(); hostBuilder.Properties[typeof(ParseResult)] = parseResult; + // As long as done before first await, + // we can set the process termination timeout to null + // and let the .NET Host ConsoleLifetime deal with termination + parseResult.Configuration.ProcessTerminationTimeout = null; + hostBuilder.UseConsoleLifetime(); + if (parseResult.Configuration.RootCommand is RootCommand root && root.Directives.SingleOrDefault(d => d.Name == HostingDirectiveName) is { } directive) { @@ -72,7 +78,6 @@ public override async Task InvokeAsync(ParseResult parseResult, Cancellatio var bindingContext = GetBindingContext(parseResult); int registeredBefore = 0; - hostBuilder.UseInvocationLifetime(); hostBuilder.ConfigureServices(services => { services.AddSingleton(parseResult); diff --git a/src/System.CommandLine.Hosting/HostingExtensions.cs b/src/System.CommandLine.Hosting/HostingExtensions.cs index 1266704011..0451b00cfb 100644 --- a/src/System.CommandLine.Hosting/HostingExtensions.cs +++ b/src/System.CommandLine.Hosting/HostingExtensions.cs @@ -31,16 +31,6 @@ public static CommandLineConfiguration UseHost( Action configureHost = null ) => UseHost(config, null, configureHost); - public static IHostBuilder UseInvocationLifetime(this IHostBuilder host, Action configureOptions = null) - { - return host.ConfigureServices(services => - { - services.AddSingleton(); - if (configureOptions is Action) - services.Configure(configureOptions); - }); - } - public static OptionsBuilder BindCommandLine( this OptionsBuilder optionsBuilder) where TOptions : class diff --git a/src/System.CommandLine.Hosting/InvocationLifetime.cs b/src/System.CommandLine.Hosting/InvocationLifetime.cs deleted file mode 100644 index be974776ac..0000000000 --- a/src/System.CommandLine.Hosting/InvocationLifetime.cs +++ /dev/null @@ -1,100 +0,0 @@ -// Copyright (c) .NET Foundation and contributors. All rights reserved. -// Licensed under the MIT license. See LICENSE file in the project root for full license information. - -using System.Threading; -using System.Threading.Tasks; - -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Options; - -#if NETSTANDARD2_0 -using IHostEnvironment = Microsoft.Extensions.Hosting.IHostingEnvironment; -using IHostApplicationLifetime = Microsoft.Extensions.Hosting.IApplicationLifetime; -#endif - -namespace System.CommandLine.Hosting -{ - public class InvocationLifetime : IHostLifetime - { - private CancellationTokenRegistration invokeCancelReg; - private CancellationTokenRegistration appStartedReg; - private CancellationTokenRegistration appStoppingReg; - - public InvocationLifetime( - IOptions options, - IHostEnvironment environment, - IHostApplicationLifetime applicationLifetime, - ILoggerFactory loggerFactory = null) - { - Options = options?.Value ?? throw new ArgumentNullException(nameof(options)); - Environment = environment - ?? throw new ArgumentNullException(nameof(environment)); - ApplicationLifetime = applicationLifetime - ?? throw new ArgumentNullException(nameof(applicationLifetime)); - - Logger = (loggerFactory ?? NullLoggerFactory.Instance) - .CreateLogger("Microsoft.Hosting.Lifetime"); - } - - public InvocationLifetimeOptions Options { get; } - private ILogger Logger { get; } - public IHostEnvironment Environment { get; } - public IHostApplicationLifetime ApplicationLifetime { get; } - - public Task WaitForStartAsync(CancellationToken cancellationToken) - { - if (!Options.SuppressStatusMessages) - { - appStartedReg = ApplicationLifetime.ApplicationStarted.Register(state => - { - ((InvocationLifetime)state).OnApplicationStarted(); - }, this); - appStoppingReg = ApplicationLifetime.ApplicationStopping.Register(state => - { - ((InvocationLifetime)state).OnApplicationStopping(); - }, this); - } - - // The token comes from HostingAction.InvokeAsync - // and it's the invocation cancellation token. - invokeCancelReg = cancellationToken.Register(state => - { - ((InvocationLifetime)state).OnInvocationCancelled(); - }, this); - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - // There's nothing to do here - return Task.CompletedTask; - } - - private void OnInvocationCancelled() - { - ApplicationLifetime.StopApplication(); - } - - private void OnApplicationStarted() - { - Logger.LogInformation("Application started. Press Ctrl+C to shut down."); - Logger.LogInformation("Hosting environment: {envName}", Environment.EnvironmentName); - Logger.LogInformation("Content root path: {contentRoot}", Environment.ContentRootPath); - } - - private void OnApplicationStopping() - { - Logger.LogInformation("Application is shutting down..."); - } - - public void Dispose() - { - invokeCancelReg.Dispose(); - appStartedReg.Dispose(); - appStoppingReg.Dispose(); - } - } -} diff --git a/src/System.CommandLine.Hosting/InvocationLifetimeOptions.cs b/src/System.CommandLine.Hosting/InvocationLifetimeOptions.cs deleted file mode 100644 index 7d8f1c1946..0000000000 --- a/src/System.CommandLine.Hosting/InvocationLifetimeOptions.cs +++ /dev/null @@ -1,8 +0,0 @@ -using Microsoft.Extensions.Hosting; - -namespace System.CommandLine.Hosting -{ - public class InvocationLifetimeOptions : ConsoleLifetimeOptions - { - } -} From ba5425c248b59ef15618f64ce951ec55a01073f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B8is=C3=A6ther=20Rasch?= Date: Mon, 31 Mar 2025 10:19:52 +0200 Subject: [PATCH 3/5] Fix legacy mention of InvocationContext in error message for GetParseResult methods --- src/System.CommandLine.Hosting/HostingExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/System.CommandLine.Hosting/HostingExtensions.cs b/src/System.CommandLine.Hosting/HostingExtensions.cs index 0451b00cfb..186925672f 100644 --- a/src/System.CommandLine.Hosting/HostingExtensions.cs +++ b/src/System.CommandLine.Hosting/HostingExtensions.cs @@ -63,7 +63,7 @@ public static ParseResult GetParseResult(this IHostBuilder hostBuilder) ctxObj is ParseResult invocationContext) return invocationContext; - throw new InvalidOperationException("Host builder has no Invocation Context registered to it."); + throw new InvalidOperationException("Host builder has no command-line parse result registered to it."); } public static ParseResult GetParseResult(this HostBuilderContext context) @@ -74,7 +74,7 @@ public static ParseResult GetParseResult(this HostBuilderContext context) ctxObj is ParseResult invocationContext) return invocationContext; - throw new InvalidOperationException("Host builder has no Invocation Context registered to it."); + throw new InvalidOperationException("Host builder context has no command-line parse result registered to it."); } public static IHost GetHost(this ParseResult parseResult) From 6a562ce00479ab1d95141745dc613cefc4f44b74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B8is=C3=A6ther=20Rasch?= Date: Mon, 31 Mar 2025 23:01:17 +0200 Subject: [PATCH 4/5] Delete HostingAction and remove BindingHandler dependency and extension methods --- .../HostingAction.cs | 125 ------------------ .../HostingExtensions.cs | 58 +------- .../System.CommandLine.Hosting.csproj | 2 +- 3 files changed, 2 insertions(+), 183 deletions(-) delete mode 100644 src/System.CommandLine.Hosting/HostingAction.cs diff --git a/src/System.CommandLine.Hosting/HostingAction.cs b/src/System.CommandLine.Hosting/HostingAction.cs deleted file mode 100644 index 117abadc17..0000000000 --- a/src/System.CommandLine.Hosting/HostingAction.cs +++ /dev/null @@ -1,125 +0,0 @@ -using System.Collections.Generic; -using System.CommandLine.Binding; -using System.CommandLine.Invocation; -using System.CommandLine.NamingConventionBinder; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -namespace System.CommandLine.Hosting -{ - // It's a wrapper, that configures the host, starts it and then runs the actual action. - internal sealed class HostingAction : BindingHandler - { - internal const string HostingDirectiveName = "config"; - - private readonly Func _hostBuilderFactory; - private readonly Action _configureHost; - private readonly AsynchronousCommandLineAction _actualAction; - - internal static void SetHandlers(Command command, Func hostBuilderFactory, Action configureHost) - { - command.Action = new HostingAction(hostBuilderFactory, configureHost, (AsynchronousCommandLineAction)command.Action); - command.TreatUnmatchedTokensAsErrors = false; // to pass unmatched Tokens to host builder factory - - foreach (Command subCommand in command.Subcommands) - { - SetHandlers(subCommand, hostBuilderFactory, configureHost); - } - } - - private HostingAction(Func hostBuilderFactory, Action configureHost, AsynchronousCommandLineAction actualAction) - { - _hostBuilderFactory = hostBuilderFactory; - _configureHost = configureHost; - _actualAction = actualAction; - } - - public override BindingContext GetBindingContext(ParseResult parseResult) - => _actualAction is BindingHandler bindingHandler - ? bindingHandler.GetBindingContext(parseResult) - : base.GetBindingContext(parseResult); - - public override async Task InvokeAsync(ParseResult parseResult, CancellationToken cancellationToken = default) - { - var argsRemaining = parseResult.UnmatchedTokens; - var hostBuilder = _hostBuilderFactory?.Invoke(argsRemaining.ToArray()) - ?? new HostBuilder(); - hostBuilder.Properties[typeof(ParseResult)] = parseResult; - - // As long as done before first await, - // we can set the process termination timeout to null - // and let the .NET Host ConsoleLifetime deal with termination - parseResult.Configuration.ProcessTerminationTimeout = null; - hostBuilder.UseConsoleLifetime(); - - if (parseResult.Configuration.RootCommand is RootCommand root && - root.Directives.SingleOrDefault(d => d.Name == HostingDirectiveName) is { } directive) - { - if (parseResult.GetResult(directive) is { } directiveResult) - { - hostBuilder.ConfigureHostConfiguration(config => - { - var kvpSeparator = new[] { '=' }; - - config.AddInMemoryCollection(directiveResult.Values.Select(s => - { - var parts = s.Split(kvpSeparator, count: 2); - var key = parts[0]; - var value = parts.Length > 1 ? parts[1] : null; - return new KeyValuePair(key, value); - }).ToList()); - }); - } - } - - var bindingContext = GetBindingContext(parseResult); - int registeredBefore = 0; - hostBuilder.ConfigureServices(services => - { - services.AddSingleton(parseResult); - services.AddSingleton(bindingContext); - - registeredBefore = services.Count; - }); - - if (_configureHost is not null) - { - _configureHost.Invoke(hostBuilder); - - hostBuilder.ConfigureServices(services => - { - // "_configureHost" just registered types that might be needed in BindingContext - for (int i = registeredBefore; i < services.Count; i++) - { - Type captured = services[i].ServiceType; - bindingContext.AddService(captured, c => c.GetService().Services.GetService(captured)); - } - }); - } - - using var host = hostBuilder.Build(); - - bindingContext.AddService(typeof(IHost), _ => host); - - await host.StartAsync(cancellationToken); - - try - { - if (_actualAction is not null) - { - return await _actualAction.InvokeAsync(parseResult, cancellationToken); - } - - return 0; - } - finally - { - await host.StopAsync(cancellationToken); - } - } - } -} \ No newline at end of file diff --git a/src/System.CommandLine.Hosting/HostingExtensions.cs b/src/System.CommandLine.Hosting/HostingExtensions.cs index 186925672f..804e894185 100644 --- a/src/System.CommandLine.Hosting/HostingExtensions.cs +++ b/src/System.CommandLine.Hosting/HostingExtensions.cs @@ -1,60 +1,11 @@ -using System.CommandLine.Binding; -using System.CommandLine.Invocation; -using System.CommandLine.NamingConventionBinder; -using CommandHandler = System.CommandLine.NamingConventionBinder.CommandHandler; +#nullable enable -using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Options; namespace System.CommandLine.Hosting { public static class HostingExtensions { - public static CommandLineConfiguration UseHost( - this CommandLineConfiguration config, - Func hostBuilderFactory, - Action configureHost = null) - { - if (config.RootCommand is RootCommand root) - { - root.Add(new Directive(HostingAction.HostingDirectiveName)); - } - - HostingAction.SetHandlers(config.RootCommand, hostBuilderFactory, configureHost); - - return config; - } - - public static CommandLineConfiguration UseHost( - this CommandLineConfiguration config, - Action configureHost = null - ) => UseHost(config, null, configureHost); - - public static OptionsBuilder BindCommandLine( - this OptionsBuilder optionsBuilder) - where TOptions : class - { - if (optionsBuilder is null) - throw new ArgumentNullException(nameof(optionsBuilder)); - return optionsBuilder.Configure((opts, serviceProvider) => - { - var modelBinder = serviceProvider - .GetService>() - ?? new ModelBinder(); - var bindingContext = serviceProvider.GetRequiredService(); - modelBinder.UpdateInstance(opts, bindingContext); - }); - } - - public static Command UseCommandHandler(this Command command) - where THandler : CommandLineAction - { - command.Action = CommandHandler.Create(typeof(THandler).GetMethod(nameof(AsynchronousCommandLineAction.InvokeAsync))); - - return command; - } - public static ParseResult GetParseResult(this IHostBuilder hostBuilder) { _ = hostBuilder ?? throw new ArgumentNullException(nameof(hostBuilder)); @@ -76,12 +27,5 @@ public static ParseResult GetParseResult(this HostBuilderContext context) throw new InvalidOperationException("Host builder context has no command-line parse result registered to it."); } - - public static IHost GetHost(this ParseResult parseResult) - { - _ = parseResult ?? throw new ArgumentNullException(paramName: nameof(parseResult)); - var hostModelBinder = new ModelBinder(); - return (IHost)hostModelBinder.CreateInstance(parseResult.GetBindingContext()); - } } } diff --git a/src/System.CommandLine.Hosting/System.CommandLine.Hosting.csproj b/src/System.CommandLine.Hosting/System.CommandLine.Hosting.csproj index f8e403ebf6..b24feccca3 100644 --- a/src/System.CommandLine.Hosting/System.CommandLine.Hosting.csproj +++ b/src/System.CommandLine.Hosting/System.CommandLine.Hosting.csproj @@ -1,6 +1,7 @@ + 12 true netstandard2.0;netstandard2.1;$(TargetFrameworkForNETSDK) This package provides support for using System.CommandLine with Microsoft.Extensions.Hosting. @@ -20,7 +21,6 @@ - From 7f36552944ededd4f9e13f5de21c35f749560431 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B8is=C3=A6ther=20Rasch?= Date: Mon, 31 Mar 2025 23:28:45 +0200 Subject: [PATCH 5/5] Add new HostingAction that uses .NET Generic Host Hosted Service --- Directory.Packages.props | 10 +- .../HostApplicationBuilderAction.cs | 150 +++++++ .../HostConfigurationDirective.cs | 56 +++ .../HostingAction.cs | 81 ++++ .../HostingActionService.cs | 65 +++ .../HostingExtensions.cs | 380 +++++++++++++++++- .../IHostingActionInvocation.cs | 9 + .../System.CommandLine.Hosting.csproj | 1 + 8 files changed, 748 insertions(+), 4 deletions(-) create mode 100644 src/System.CommandLine.Hosting/HostApplicationBuilderAction.cs create mode 100644 src/System.CommandLine.Hosting/HostConfigurationDirective.cs create mode 100644 src/System.CommandLine.Hosting/HostingAction.cs create mode 100644 src/System.CommandLine.Hosting/HostingActionService.cs create mode 100644 src/System.CommandLine.Hosting/IHostingActionInvocation.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f46c178e87..e55472577b 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -14,9 +14,15 @@ - + + 6.0.0 + 8.0.0 + - + + 6.0.0 + 8.0.0 + diff --git a/src/System.CommandLine.Hosting/HostApplicationBuilderAction.cs b/src/System.CommandLine.Hosting/HostApplicationBuilderAction.cs new file mode 100644 index 0000000000..dc2ad73749 --- /dev/null +++ b/src/System.CommandLine.Hosting/HostApplicationBuilderAction.cs @@ -0,0 +1,150 @@ +#if NET8_0_OR_GREATER +using System.Collections.Generic; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace System.CommandLine.Hosting; + +public class HostApplicationBuilderAction() : HostingAction() +{ + private new readonly Func? _createHostBuilder; + public new Action? ConfigureHost { get; set; } + + protected override IHostBuilder CreateHostBuiderCore(string[] args) + { + var hostBuilder = _createHostBuilder?.Invoke(args) ?? + new HostApplicationBuilder(args); + return new HostApplicationBuilderWrapper(hostBuilder); + } + + protected override void ConfigureHostBuilder(IHostBuilder hostBuilder) + { + base.ConfigureHostBuilder(hostBuilder); + ConfigureHost?.Invoke(GetHostApplicationBuilder(hostBuilder)); + } + + private static HostApplicationBuilder GetHostApplicationBuilder( + IHostBuilder hostBuilder + ) + { + return (HostApplicationBuilder)hostBuilder + .Properties[typeof(HostApplicationBuilder)]; + } + + private class HostApplicationBuilderWrapper( + HostApplicationBuilder hostApplicationBuilder + ) : IHostBuilder + { + private Action? _useServiceProviderFactoryAction; + private object? _configureServiceProviderBuilderAction; + + public HostBuilderContext Context { get; } = new( + ((IHostApplicationBuilder)hostApplicationBuilder).Properties + ) + { + Configuration = hostApplicationBuilder.Configuration, + HostingEnvironment = hostApplicationBuilder.Environment, + Properties = + { { typeof(HostApplicationBuilder), hostApplicationBuilder } } + }; + + public IDictionary Properties => + ((IHostApplicationBuilder)hostApplicationBuilder).Properties; + + public IHost Build() + { + _useServiceProviderFactoryAction?.Invoke(); + return hostApplicationBuilder.Build(); + } + + public IHostBuilder ConfigureHostConfiguration( + Action configureDelegate + ) + { + configureDelegate?.Invoke(hostApplicationBuilder.Configuration); + return this; + } + + public IHostBuilder ConfigureAppConfiguration( + Action configureDelegate + ) + { + SynchronizeContext(); + configureDelegate?.Invoke( + Context, + hostApplicationBuilder.Configuration + ); + SynchronizeContext(); + return this; + } + + public IHostBuilder ConfigureServices( + Action configureDelegate + ) + { + SynchronizeContext(); + configureDelegate?.Invoke(Context, hostApplicationBuilder.Services); + SynchronizeContext(); + return this; + } + + IHostBuilder IHostBuilder.UseServiceProviderFactory( + IServiceProviderFactory factory + ) + { + _useServiceProviderFactoryAction = () => + { + Action? configureDelegate = null; + if (_configureServiceProviderBuilderAction is Action configureDelegateWithContext) + { + configureDelegate = builder => + { + SynchronizeContext(); + configureDelegateWithContext(Context, builder); + SynchronizeContext(); + }; + } + hostApplicationBuilder.ConfigureContainer(factory, configureDelegate); + }; + return this; + } + + IHostBuilder IHostBuilder.UseServiceProviderFactory( + Func> factory + ) + { + _useServiceProviderFactoryAction = () => + { + Action? configureDelegate = null; + if (_configureServiceProviderBuilderAction is Action configureDelegateWithContext) + { + configureDelegate = builder => + { + SynchronizeContext(); + configureDelegateWithContext(Context, builder); + SynchronizeContext(); + }; + } + var factoryInstance = factory(Context); + hostApplicationBuilder.ConfigureContainer(factoryInstance, configureDelegate); + }; + return this; + } + + IHostBuilder IHostBuilder.ConfigureContainer( + Action configureDelegate + ) + { + _configureServiceProviderBuilderAction = configureDelegate; + return this; + } + + private void SynchronizeContext() + { + Context.Configuration = hostApplicationBuilder.Configuration; + Context.HostingEnvironment = hostApplicationBuilder.Environment; + } + } +} +#endif \ No newline at end of file diff --git a/src/System.CommandLine.Hosting/HostConfigurationDirective.cs b/src/System.CommandLine.Hosting/HostConfigurationDirective.cs new file mode 100644 index 0000000000..e316bbef72 --- /dev/null +++ b/src/System.CommandLine.Hosting/HostConfigurationDirective.cs @@ -0,0 +1,56 @@ +using System.Collections.Generic; +using System.Linq; + +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; + +using System.CommandLine.Parsing; + +namespace System.CommandLine.Hosting; + +public class HostConfigurationDirective() : Directive(Name) +{ + public new const string Name = "config"; + + internal static void ConfigureHostBuilder(IHostBuilder hostBuilder) + { + var parseResult = hostBuilder.GetParseResult(); + if (parseResult.Configuration.RootCommand is RootCommand rootCommand && + rootCommand.Directives.FirstOrDefault(IsConfigDirective) + is Directive configDirective && + parseResult.GetResult(configDirective) + is DirectiveResult configResult + ) + { + var configKvps = configResult.Values.Select(GetKeyValuePair) + .ToList(); + hostBuilder.ConfigureHostConfiguration( + (config) => config.AddInMemoryCollection(configKvps) + ); + } + + static bool IsConfigDirective(Directive directive) => + string.Equals(directive.Name, Name, StringComparison.OrdinalIgnoreCase); + + [Diagnostics.CodeAnalysis.SuppressMessage( + "Style", + "IDE0057: Use range operator", + Justification = ".NET Standard 2.0" + )] + static KeyValuePair GetKeyValuePair(string configDirective) + { + ReadOnlySpan kvpSpan = configDirective.AsSpan(); + int eqlIdx = kvpSpan.IndexOf('='); + string key; + string? value = default; + if (eqlIdx < 0) + key = kvpSpan.Trim().ToString(); + else + { + key = kvpSpan.Slice(0, eqlIdx).Trim().ToString(); + value = kvpSpan.Slice(eqlIdx + 1).Trim().ToString(); + } + return new KeyValuePair(key, value); + } + } +} diff --git a/src/System.CommandLine.Hosting/HostingAction.cs b/src/System.CommandLine.Hosting/HostingAction.cs new file mode 100644 index 0000000000..34bc7b1afa --- /dev/null +++ b/src/System.CommandLine.Hosting/HostingAction.cs @@ -0,0 +1,81 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +using System.CommandLine.Invocation; + +namespace System.CommandLine.Hosting; + +public class HostingAction() : AsynchronousCommandLineAction() +{ + protected readonly Func? _createHostBuilder; + internal Action? ConfigureHost { get; set; } + public Action? ConfigureServices { get; set; } + + protected virtual IHostBuilder CreateHostBuiderCore(string[] args) + { + var hostBuilder = _createHostBuilder?.Invoke(args) ?? + new HostBuilder(); + return hostBuilder; + } + + protected virtual void ConfigureHostBuilder(IHostBuilder hostBuilder) + { + ConfigureHost?.Invoke(hostBuilder); + if (ConfigureServices is not null) + { + hostBuilder.ConfigureServices(ConfigureServices); + } + } + + public override async Task InvokeAsync( + ParseResult parseResult, + CancellationToken cancellationToken = default + ) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(parseResult); +#else + _ = parseResult ?? throw new ArgumentNullException(nameof(parseResult)); +#endif + + string[] unmatchedTokens = parseResult.UnmatchedTokens?.ToArray() ?? []; + IHostBuilder hostBuilder = CreateHostBuiderCore(unmatchedTokens); + hostBuilder.Properties[typeof(ParseResult)] = parseResult; + + // As long as done before first await + // ProcessTerminationTimeout can be set to null + // so that .NET Generic Host can control console lifetime instead. + parseResult.Configuration.ProcessTerminationTimeout = null; + hostBuilder.UseConsoleLifetime(); + + hostBuilder.ConfigureServices(static (context, services) => + { + var parseResult = context.GetParseResult(); + var hostingAction = parseResult.GetHostingAction(); + services.AddSingleton(parseResult); + services.AddSingleton(parseResult.Configuration); + services.AddHostedService(); + // TODO: add IHostingActionInvocation singleton + }); + + ConfigureHostBuilder(hostBuilder); + + using var host = hostBuilder.Build(); + await host.StartAsync(cancellationToken) + .ConfigureAwait(continueOnCapturedContext: false); + + var appRunningTask = host.WaitForShutdownAsync(cancellationToken); + + // TODO: Retrieve ExecuteTask from HostingActionService to get result + Task invocationTask = Task.FromResult(0); + + await appRunningTask.ConfigureAwait(continueOnCapturedContext: false); + + return await invocationTask + .ConfigureAwait(continueOnCapturedContext: false); + } +} \ No newline at end of file diff --git a/src/System.CommandLine.Hosting/HostingActionService.cs b/src/System.CommandLine.Hosting/HostingActionService.cs new file mode 100644 index 0000000000..069fb46909 --- /dev/null +++ b/src/System.CommandLine.Hosting/HostingActionService.cs @@ -0,0 +1,65 @@ +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Hosting; + +namespace System.CommandLine.Hosting; + +internal class HostingActionService( + IHostApplicationLifetime lifetime, + IHostingActionInvocation invocation + ) : BackgroundService() +{ + public new Task? ExecuteTask => base.ExecuteTask as Task; + + protected override Task ExecuteAsync(CancellationToken stoppingToken) + { + return WaitForStartAndInvokeAsync(stoppingToken); + } + + private async Task WaitForApplicationStarted(CancellationToken cancelToken) + { + TaskCompletionSource appStarted = new(); + using var startedReg = lifetime.ApplicationStarted + .Register(SetTaskComplete, appStarted); + using var preStartCancelReg = cancelToken + .Register(SetTaskCanceled, appStarted); + + await appStarted.Task + .ConfigureAwait(continueOnCapturedContext: false); + + static void SetTaskComplete(object? state) + { + var tcs = (TaskCompletionSource)state!; + tcs.TrySetResult(default); + } + + static void SetTaskCanceled(object? state) + { + var tcs = (TaskCompletionSource)state!; + tcs.TrySetCanceled( + CancellationToken.None + ); + } + } + + private async Task WaitForStartAndInvokeAsync(CancellationToken cancelToken) + { + await WaitForApplicationStarted(cancelToken) + .ConfigureAwait(continueOnCapturedContext: false); + try + { + int result = await invocation.InvokeAsync(cancelToken) + .ConfigureAwait(continueOnCapturedContext: false); + return result; + } + finally + { + // If the application is not already shut down or shutting down, + // make sure that application is shutting down now. + if (!lifetime.ApplicationStopping.IsCancellationRequested) + { + lifetime.StopApplication(); + } + } + } +} \ No newline at end of file diff --git a/src/System.CommandLine.Hosting/HostingExtensions.cs b/src/System.CommandLine.Hosting/HostingExtensions.cs index 804e894185..2057060f3d 100644 --- a/src/System.CommandLine.Hosting/HostingExtensions.cs +++ b/src/System.CommandLine.Hosting/HostingExtensions.cs @@ -1,7 +1,8 @@ -#nullable enable - +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using System.CommandLine.Parsing; + namespace System.CommandLine.Hosting { public static class HostingExtensions @@ -27,5 +28,380 @@ public static ParseResult GetParseResult(this HostBuilderContext context) throw new InvalidOperationException("Host builder context has no command-line parse result registered to it."); } + + internal static HostingAction GetHostingAction(this ParseResult parseResult) + { + if (!parseResult.TryGetHostingAction(out var hostingAction)) + throw new InvalidOperationException("Command-line parse result is not for a command with an associated .NET Generic Host command-line action."); +#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER + return hostingAction; +#else + return hostingAction!; +#endif + } + + internal static bool TryGetHostingAction( + this ParseResult parseResult, +#if NETSTANDARD2_1_OR_GREATER || NET5_0_OR_GREATER + [Diagnostics.CodeAnalysis.NotNullWhen(returnValue: true)] +#endif + out HostingAction? hostingAction + ) + { + hostingAction = parseResult.CommandResult.Command.Action + as HostingAction; + return hostingAction is not null; + } + + private static void ConfigureHostBuilderOnSymbolValidation( + this Symbol symbol, + Action< + IHostBuilder, + SymbolResult + > configureHostBuilderAction + ) + { + switch (symbol) + { + case Argument argument: + argument.Validators.Add(SymbolResultAction); + break; + case Option option: + option.Validators.Add(SymbolResultAction); + break; + case Command command: + command.Validators.Add(SymbolResultAction); + break; + default: + throw new InvalidOperationException(); + } + + void SymbolResultAction(SymbolResult symbolResult) + { + CommandResult? commandResult = null; + // Find nearest parent symbol result starting from current, + // stop when a CommandResult is found + for (SymbolResult? parentResult = symbolResult; + parentResult is not null && + (commandResult = parentResult as CommandResult) is null; + parentResult = parentResult.Parent) + { } + + // No CommandResult was found, strange but nothing to do here. + if (commandResult is null) return; + + // CommandResult was found, but Command action is not a + // .NET Generic Host action, so nothing to do in that case + if (commandResult.Command.Action is not HostingAction hostingAction) + { return; } + + // .NET Generic Host action identitfied, + // register Model Binder configuration action + hostingAction.ConfigureHost += hostBuilder => + configureHostBuilderAction(hostBuilder, symbolResult); + } + } + + private static void ConfigureSymbolOptionsServices( + this Symbol symbol, + Action optionsInstanceApplyValueAction, + Action< + HostBuilderContext, + IServiceCollection, + Action + > configureServicesAction + ) where TOptions : class + { + symbol.ConfigureHostBuilderOnSymbolValidation(ConfigureHostBuilder); + + void ConfigureHostBuilder( + IHostBuilder hostBuilder, + SymbolResult symbolResult + ) + { + hostBuilder.ConfigureServices( + (context, services) => + ConfigureServices(context, services, symbolResult) + ); + } + + void ConfigureServices( + HostBuilderContext context, + IServiceCollection services, + SymbolResult symbolResult + ) + { + configureServicesAction(context, services, options => + { + TValue? symbolValue = symbolResult switch + { + ArgumentResult { Argument: Argument argument } => + symbolResult.GetValue(argument), + OptionResult { Option: Option option } => + symbolResult.GetValue(option), + _ => throw new InvalidOperationException() + }; + optionsInstanceApplyValueAction(options, symbolValue); + }); + } + } + + public static Option ConfigureOptionsInstance( + this Option option, + string? optionsName, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(option); +#else + _ = option ?? throw new ArgumentNullException(nameof(option)); +#endif + option.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .Configure(optionsName, configureAction) + ); + return option; + } + + public static Argument ConfigureOptionsInstance( + this Argument argument, + string? optionsName, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(argument); +#else + _ = argument ?? throw new ArgumentNullException(nameof(argument)); +#endif + argument.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .Configure(optionsName, configureAction) + ); + return argument; + } + + public static Option ConfigureOptionsInstance( + this Option option, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(option); +#else + _ = option ?? throw new ArgumentNullException(nameof(option)); +#endif + option.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .Configure(configureAction) + ); + return option; + } + + public static Argument ConfigureOptionsInstance( + this Argument argument, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(argument); +#else + _ = argument ?? throw new ArgumentNullException(nameof(argument)); +#endif + argument.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .Configure(configureAction) + ); + return argument; + } + + public static Option ConfigureAllOptionsInstances( + this Option option, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(option); +#else + _ = option ?? throw new ArgumentNullException(nameof(option)); +#endif + option.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .ConfigureAll(configureAction) + ); + return option; + } + + public static Argument ConfigureAllOptionsInstances( + this Argument argument, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(argument); +#else + _ = argument ?? throw new ArgumentNullException(nameof(argument)); +#endif + argument.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .ConfigureAll(configureAction) + ); + return argument; + } + + public static Option PostConfigureOptionsInstance( + this Option option, + string? optionsName, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(option); +#else + _ = option ?? throw new ArgumentNullException(nameof(option)); +#endif + option.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .PostConfigure(optionsName, configureAction) + ); + return option; + } + + public static Argument PostConfigureOptionsInstance( + this Argument argument, + string? optionsName, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(argument); +#else + _ = argument ?? throw new ArgumentNullException(nameof(argument)); +#endif + argument.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .PostConfigure(optionsName, configureAction) + ); + return argument; + } + + public static Option PostConfigureOptionsInstance( + this Option option, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(option); +#else + _ = option ?? throw new ArgumentNullException(nameof(option)); +#endif + option.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .PostConfigure(configureAction) + ); + return option; + } + + public static Argument PostConfigureOptionsInstance( + this Argument argument, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(argument); +#else + _ = argument ?? throw new ArgumentNullException(nameof(argument)); +#endif + argument.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .PostConfigure(configureAction) + ); + return argument; + } + + public static Option PostConfigureAllOptionsInstances( + this Option option, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(option); +#else + _ = option ?? throw new ArgumentNullException(nameof(option)); +#endif + option.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .PostConfigureAll(configureAction) + ); + return option; + } + + public static Argument PostConfigureAllOptionsInstances( + this Argument argument, + Action optionsInstanceApplyValueAction + ) where TOptions : class + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(argument); +#else + _ = argument ?? throw new ArgumentNullException(nameof(argument)); +#endif + argument.ConfigureSymbolOptionsServices( + optionsInstanceApplyValueAction, + (_, services, configureAction) => services + .PostConfigureAll(configureAction) + ); + return argument; + } + + public static Command ConfigureHostBuilder( + this Command command, + Action hostBuilderAction + ) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(command); +#else + _ = command ?? throw new ArgumentNullException(nameof(command)); +#endif + command.ConfigureHostBuilderOnSymbolValidation( + (hostBuilder, symbolResult) => hostBuilderAction?.Invoke( + hostBuilder, + (CommandResult)symbolResult + ) + ); + return command; + } + + public static RootCommand ConfigureHostBuilder( + this RootCommand rootCommand, + Action hostBuilderAction + ) + { +#if NET6_0_OR_GREATER + ArgumentNullException.ThrowIfNull(rootCommand); +#else + _ = rootCommand ?? throw new ArgumentNullException(nameof(rootCommand)); +#endif + rootCommand.ConfigureHostBuilderOnSymbolValidation( + (hostBuilder, symbolResult) => hostBuilderAction?.Invoke( + hostBuilder, + (CommandResult)symbolResult + ) + ); + return rootCommand; + } } } diff --git a/src/System.CommandLine.Hosting/IHostingActionInvocation.cs b/src/System.CommandLine.Hosting/IHostingActionInvocation.cs new file mode 100644 index 0000000000..201bfa078c --- /dev/null +++ b/src/System.CommandLine.Hosting/IHostingActionInvocation.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace System.CommandLine.Hosting; + +public interface IHostingActionInvocation +{ + Task InvokeAsync(CancellationToken cancelToken = default); +} diff --git a/src/System.CommandLine.Hosting/System.CommandLine.Hosting.csproj b/src/System.CommandLine.Hosting/System.CommandLine.Hosting.csproj index b24feccca3..e5642b918a 100644 --- a/src/System.CommandLine.Hosting/System.CommandLine.Hosting.csproj +++ b/src/System.CommandLine.Hosting/System.CommandLine.Hosting.csproj @@ -5,6 +5,7 @@ true netstandard2.0;netstandard2.1;$(TargetFrameworkForNETSDK) This package provides support for using System.CommandLine with Microsoft.Extensions.Hosting. + enable