Skip to content

Add generic host builder pattern [WIP] #919

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

Draft
wants to merge 4 commits into
base: main
Choose a base branch
from
Draft
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
48 changes: 47 additions & 1 deletion src/System.CommandLine.Hosting.Tests/HostingTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Linq;

using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;

using Microsoft.Extensions.Configuration;
Expand Down Expand Up @@ -228,6 +229,51 @@ public static void UseHost_binds_parsed_arguments_to_options()
Assert.Equal(myValue, options.MyArgument);
}


[Fact(Skip ="WIP")]
public static async Task CommandLineHost_creates_host_for_simple_command()
{
//Arrange
// var args = new string[] { $"--foo", "42" };
MyOptions options = null;
IHost hostToBind = null;

var rootCmd = new RootCommand();
rootCmd.AddOption(new Option($"--foo") { Argument = new Argument<int>() });
rootCmd.Handler = CommandHandler.Create<IHost>((host) =>
{
hostToBind = host;
});
// Act
// var tokenSource = new CancellationTokenSource(TimeSpan.FromSeconds(1));
CancellationTokenSource tokenSource = null;
await CommandLineHost.CreateDefaultBuilder()
.ConfigureCommandLineDefaults((CommandLineBuilder builder) =>
{
// TODO: it is not possible to add it like this atm.
builder.AddCommand(rootCmd);
})
.Build()
.RunAsync(tokenSource?.Token ?? default);
// Assert
Assert.NotNull(hostToBind);
Assert.Equal(42, options.MyArgument);
}

[Fact]
public static async Task CommandLineHost_contains_errors_in_ParseResult_service_for_not_mapped_input()
{
//Arrange
// Act
var host = CommandLineHost.CreateDefaultBuilder(new string[]{"--foo", "bar"})
.ConfigureCommandLineDefaults((CommandLineBuilder builder) => {})
.Build();
var parseResult = host.Services.GetService<ParseResult>();
await host.StartAsync();
parseResult.Errors.Should().NotBeEmpty();
//TODO: clarify how parsing errors and command execution exceptions should be handled
}

private class MyOptions
{
public int MyArgument { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

<ItemGroup>
<PackageReference Include="FluentAssertions" Version="5.10.3" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.CommandLine" Version="3.1.4" />
</ItemGroup>

<ItemGroup Condition="'$(DisableArcade)' == '1'">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;

namespace System.CommandLine.Hosting
{
internal class CommandLineExecutorService : IHostedService
{
private readonly ILogger<CommandLineExecutorService> logger;
private readonly IHostApplicationLifetime appLifetime;
private readonly IHost host;
private readonly InvocationContext invocation;
private readonly ParseResult parseResult;

public CommandLineExecutorService(
ILogger<CommandLineExecutorService> logger,
IHostApplicationLifetime appLifetime,
IHost host,
InvocationContext invocation,
ParseResult parseResult)
{
this.logger = logger;
this.appLifetime = appLifetime;
this.host = host;
this.invocation = invocation;
this.parseResult = parseResult;
}

public async Task StartAsync(CancellationToken cancellationToken)
{
invocation.BindingContext.AddService(typeof(IHost), _ => host);
if(parseResult.Errors.Any())
{
logger.LogWarning($"Executing {nameof(Parser)} with errors: {parseResult.Errors.Count}");
}
await parseResult.InvokeAsync();
appLifetime.StopApplication();
}

public Task StopAsync(CancellationToken cancellationToken)
{
return Task.CompletedTask;
}
}
}
39 changes: 39 additions & 0 deletions src/System.CommandLine.Hosting/DefaultBuilder/CommandLineHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using Microsoft.Extensions.Hosting;
using System.CommandLine.Builder;

namespace System.CommandLine.Hosting
{
/// <summary>
/// Provides convenience methods for creating instances of <see cref="IHostBuilder"/> and with pre-configured defaults.
/// </summary>
public static class CommandLineHost
{
/// <summary>
/// Initializes a new instance of the <see cref="IHostBuilder"/> with pre-configured defaults.
/// </summary>
/// <remarks>
/// </remarks>
/// <returns>The initialized <see cref="IHostBuilder"/>.</returns>
public static IHostBuilder CreateDefaultBuilder() =>
CreateDefaultBuilder(args: null);

/// <summary>
/// Initializes a new instance of the <see cref="IHostBuilder"/> with pre-configured defaults.
/// </summary>
/// <remarks>
/// </remarks>
/// <param name="args">The command line args.</param>
/// <returns>The initialized <see cref="IHost"/>.</returns>
public static IHostBuilder CreateDefaultBuilder(string[] args)
{
var builder = Host.CreateDefaultBuilder(args)
.ConfigureCommandLineDefaults(cmdBuilder => cmdBuilder.UseDefaults(), args);
return builder;
// TODO: add remaining args parsing
// var argsRemaining = invocation.ParseResult.UnparsedTokens.ToArray();
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System;
using System.CommandLine.Builder;
using System.CommandLine.Invocation;
using System.CommandLine.Parsing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;

namespace System.CommandLine.Hosting
{
internal class GenericCommandLineHostBuilder
{

private const string ConfigurationDirectiveName = "config";
public string[] Args { get; }
private readonly IHostBuilder builder;
private CommandLineBuilder commandLineBuilder;

public GenericCommandLineHostBuilder(IHostBuilder builder, string[] args = default)
{
this.builder = builder;
commandLineBuilder = new CommandLineBuilder();
Args = args;
}

public void Configure(Action<CommandLineBuilder> configure)
{
configure(commandLineBuilder);
Parser parser = commandLineBuilder.Build();
ParseResult parseResult = parser.Parse(Args);
var invocation = new InvocationContext(parseResult);
AddSystemCommandLine(builder, invocation);
builder.ConfigureHostConfiguration(config =>
{
config.AddCommandLineDirectives(invocation.ParseResult, ConfigurationDirectiveName);
});
}

private static void AddSystemCommandLine(IHostBuilder host, InvocationContext invocation)
{
host.ConfigureServices(services =>
{
services.TryAddSingleton<InvocationContext>(invocation);
services.AddSingleton<IConsole>(invocation.Console);
// semantically it is transient dependency
services.AddTransient<IInvocationResult>(_ => invocation.InvocationResult);
services.AddTransient<ParseResult>(_ => invocation.ParseResult);
});
}

}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.CommandLine.Builder;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace System.CommandLine.Hosting
{
/// <summary>
/// Extension methods for configuring the IWebHostBuilder.
/// </summary>
public static class GenericHostBuilderExtensions
{
/// <summary>
/// Initializes a new instance of the <see cref="IHostBuilder"/> class with pre-configured defaults.
/// </summary>
/// <remarks>
/// </remarks>
/// <param name="builder">The <see cref="IHostBuilder" /> instance to configure</param>
/// <param name="configure">The configure callback</param>
/// <returns>The <see cref="IHostBuilder"/> for chaining.</returns>
public static IHostBuilder ConfigureCommandLineDefaults(
this IHostBuilder builder,
Action<CommandLineBuilder> configure,
string[] args = default)
{
AddGenericCommandLineHostBuilder(builder, args, out var cmdHostBuilder);
cmdHostBuilder.Configure(configure);
builder.ConfigureServices((context, services) =>
services.AddHostedService<CommandLineExecutorService>());
return builder;
}

/// <summary>
/// Adds <see cref="AddGenericCommandLineHostBuilder"/> to <paramref name="builder"/>.
/// </summary>
/// <param name="builder"></param>
/// <param name="args"></param>
/// <param name="cmdHostBuilder"></param>
/// <returns><see langword="true"/> when new builder is intialized</returns>
private static bool AddGenericCommandLineHostBuilder(IHostBuilder builder, string[] args, out GenericCommandLineHostBuilder cmdHostBuilder)
{
var hostBuilderState = builder.Properties;
var cacheKey = nameof(GenericCommandLineHostBuilder);
hostBuilderState.TryGetValue(cacheKey, out var initializedHost);
cmdHostBuilder = initializedHost as GenericCommandLineHostBuilder;
if (cmdHostBuilder is object)
{
return false;
}
cmdHostBuilder = new GenericCommandLineHostBuilder(builder, args);
hostBuilderState[cacheKey] = cmdHostBuilder;
return true;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="2.2.0" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="3.1.4" />
</ItemGroup>

<ItemGroup>
Expand Down