Skip to content

Commit

Permalink
feat: make CommandLineApplication asynchronous and add new async API
Browse files Browse the repository at this point in the history
Fixes #208
Fixes #153
Fixes #225
  • Loading branch information
natemcmaster committed Jul 24, 2019
1 parent 83b79da commit 431f273
Show file tree
Hide file tree
Showing 11 changed files with 198 additions and 35 deletions.
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@
* Fix [#221] by [@vpkopylov] - Use Pager for help text option only works on top-level help
* PR [#239] by [@vpkopylov] - Add check for subcommand cycle
* Support C# 8.0 and nullable reference types - [#245]
* Add async methods to CommandLineApplication
* Handle CTRL+C by default
* Fix [#208] - make `CommandLineApplication.ExecuteAsync` actually asynchronous
* Fix [#153] - add async methods that accept cancellation tokens

[#153]: https://github.com/natemcmaster/CommandLineUtils/issues/153
[#208]: https://github.com/natemcmaster/CommandLineUtils/issues/208
[#221]: https://github.com/natemcmaster/CommandLineUtils/issues/221
[#227]: https://github.com/natemcmaster/CommandLineUtils/issues/227
[#230]: https://github.com/natemcmaster/CommandLineUtils/pull/230
Expand Down
1 change: 1 addition & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<EmbedUntrackedSources>true</EmbedUntrackedSources>
<PackageIconUrl>https://natemcmaster.github.io/CommandLineUtils/logo.png</PackageIconUrl>
<NoPackageAnalysis>true</NoPackageAnalysis>
<SuppressNETCoreSdkPreviewMessage>true</SuppressNETCoreSdkPreviewMessage>
<WarningsNotAsErrors>$(WarningsNotAsErrors);1591</WarningsNotAsErrors>
<LangVersion>8.0</LangVersion>
<Nullable>enable</Nullable>
Expand Down
1 change: 1 addition & 0 deletions build.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@ Remove-Item -Recurse $artifacts -ErrorAction Ignore
exec dotnet msbuild /t:UpdateCiSettings @MSBuildArgs
exec dotnet build --configuration $Configuration '-warnaserror:CS1591' @MSBuildArgs
exec dotnet pack --no-restore --no-build --configuration $Configuration -o $artifacts @MSBuildArgs
exec dotnet build --configuration $Configuration "$PSScriptRoot/docs/samples/samples.sln"

[string[]] $testArgs=@()
if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) {
Expand Down
16 changes: 9 additions & 7 deletions docs/samples/attributes/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.Net.Http;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using McMaster.Extensions.CommandLineUtils;

Expand Down Expand Up @@ -36,7 +37,7 @@ class Program

private HttpClient _client;

private async Task<int> OnExecuteAsync(CommandLineApplication app)
private async Task<int> OnExecuteAsync(CommandLineApplication app, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(Url))
{
Expand All @@ -57,10 +58,10 @@ private async Task<int> OnExecuteAsync(CommandLineApplication app)
switch (RequestMethod)
{
case HttpMethod.Get:
result = await GetAsync(uri);
result = await GetAsync(uri, cancellationToken);
break;
case HttpMethod.Post:
result = await PostAsync(uri);
result = await PostAsync(uri, cancellationToken);
break;
default:
throw new NotImplementedException();
Expand All @@ -80,15 +81,16 @@ private void LogTrace(TraceLevel level, string message)
}
}

private async Task<HttpResponseMessage> PostAsync(Uri uri)
private async Task<HttpResponseMessage> PostAsync(Uri uri, CancellationToken cancellationToken)
{
var content = new ByteArrayContent(Encoding.ASCII.GetBytes(Data ?? string.Empty));
return await _client.PostAsync(uri, content);
return await _client.PostAsync(uri, content, cancellationToken);
}

private async Task<HttpResponseMessage> GetAsync(Uri uri)
private async Task<HttpResponseMessage> GetAsync(Uri uri, CancellationToken cancellationToken)
{
var result = await _client.GetAsync(uri);
var result = await _client.GetAsync(uri, cancellationToken);
cancellationToken.ThrowIfCancellationRequested();
var content = await result.Content.ReadAsStringAsync();

Console.WriteLine(content);
Expand Down
43 changes: 32 additions & 11 deletions src/CommandLineUtils/CommandLineApplication.Execute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using McMaster.Extensions.CommandLineUtils.Abstractions;
using McMaster.Extensions.CommandLineUtils.Internal;
Expand All @@ -28,6 +29,21 @@ partial class CommandLineApplication
/// <returns>The process exit code</returns>
public static int Execute<TApp>(CommandLineContext context)
where TApp : class
=> ExecuteAsync<TApp>(context).GetAwaiter().GetResult();

/// <summary>
/// Creates an instance of <typeparamref name="TApp"/>, matching <see cref="CommandLineContext.Arguments"/>
/// to all attributes on the type, and then invoking a method named "OnExecute" or "OnExecuteAsync" if it exists.
/// See <seealso cref="OptionAttribute" />, <seealso cref="ArgumentAttribute" />,
/// <seealso cref="HelpOptionAttribute"/>, and <seealso cref="VersionOptionAttribute"/>.
/// </summary>
/// <param name="context">The execution context.</param>
/// <param name="cancellationToken"></param>
/// <typeparam name="TApp">A type that should be bound to the arguments.</typeparam>
/// <exception cref="InvalidOperationException">Thrown when attributes are incorrectly configured.</exception>
/// <returns>The process exit code</returns>
public static async Task<int> ExecuteAsync<TApp>(CommandLineContext context, CancellationToken cancellationToken = default)
where TApp : class
{
if (context == null)
{
Expand All @@ -54,7 +70,7 @@ public static int Execute<TApp>(CommandLineContext context)
using var app = new CommandLineApplication<TApp>();
app.SetContext(context);
app.Conventions.UseDefaultConventions();
return app.Execute(context.Arguments);
return await app.ExecuteAsync(context.Arguments, cancellationToken);
}
catch (CommandParsingException ex)
{
Expand Down Expand Up @@ -116,39 +132,44 @@ public static int Execute<TApp>(IConsole console, params string[] args)
/// <returns>The process exit code</returns>
public static Task<int> ExecuteAsync<TApp>(params string[] args)
where TApp : class
=> ExecuteAsync<TApp>(PhysicalConsole.Singleton, args);
=> ExecuteAsync<TApp>(PhysicalConsole.Singleton, args);

/// <summary>
/// Creates an instance of <typeparamref name="TApp"/>, matching <paramref name="args"/>
/// to all attributes on the type, and then invoking a method named "OnExecute" or "OnExecuteAsync" if it exists.
/// See <seealso cref="OptionAttribute" />, <seealso cref="ArgumentAttribute" />,
/// <seealso cref="HelpOptionAttribute"/>, and <seealso cref="VersionOptionAttribute"/>.
/// </summary>
/// <param name="console">The console to use</param>
/// <param name="args">The arguments</param>
/// <param name="cancellationToken"></param>
/// <typeparam name="TApp">A type that should be bound to the arguments.</typeparam>
/// <exception cref="InvalidOperationException">Thrown when attributes are incorrectly configured.</exception>
/// <returns>The process exit code</returns>
public static Task<int> ExecuteAsync<TApp>(IConsole console, params string[] args)
where TApp : class
public static Task<int> ExecuteAsync<TApp>(string[] args, CancellationToken cancellationToken = default)
where TApp : class
{
args ??= Util.EmptyArray<string>();
var context = new DefaultCommandLineContext(console, Directory.GetCurrentDirectory(), args);
return ExecuteAsync<TApp>(context);
var context = new DefaultCommandLineContext(PhysicalConsole.Singleton, Directory.GetCurrentDirectory(), args);
return ExecuteAsync<TApp>(context, cancellationToken);
}

/// <summary>
/// Creates an instance of <typeparamref name="TApp"/>, matching <see cref="CommandLineContext.Arguments"/>
/// Creates an instance of <typeparamref name="TApp"/>, matching <paramref name="args"/>
/// to all attributes on the type, and then invoking a method named "OnExecute" or "OnExecuteAsync" if it exists.
/// See <seealso cref="OptionAttribute" />, <seealso cref="ArgumentAttribute" />,
/// <seealso cref="HelpOptionAttribute"/>, and <seealso cref="VersionOptionAttribute"/>.
/// </summary>
/// <param name="context">The execution context.</param>
/// <param name="console">The console to use</param>
/// <param name="args">The arguments</param>
/// <typeparam name="TApp">A type that should be bound to the arguments.</typeparam>
/// <exception cref="InvalidOperationException">Thrown when attributes are incorrectly configured.</exception>
/// <returns>The process exit code</returns>
public static Task<int> ExecuteAsync<TApp>(CommandLineContext context)
public static Task<int> ExecuteAsync<TApp>(IConsole console, params string[] args)
where TApp : class
=> Task.FromResult(Execute<TApp>(context));
{
args ??= Util.EmptyArray<string>();
var context = new DefaultCommandLineContext(console, Directory.GetCurrentDirectory(), args);
return ExecuteAsync<TApp>(context);
}
}
}
78 changes: 70 additions & 8 deletions src/CommandLineUtils/CommandLineApplication.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
using System.Linq;
using System.Reflection;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using McMaster.Extensions.CommandLineUtils.Abstractions;
using McMaster.Extensions.CommandLineUtils.Conventions;
Expand All @@ -26,6 +27,8 @@ public partial class CommandLineApplication : IServiceProvider, IDisposable
private const int HelpExitCode = 0;
internal const int ValidationErrorExitCode = 1;

private static Task<int> DefaultAction(CancellationToken ct) => Task.FromResult(0);
private Func<CancellationToken, Task<int>> _action;
private List<Action<ParseResult>>? _onParsingComplete;
internal readonly Dictionary<string, PropertyInfo> _shortOptions = new Dictionary<string, PropertyInfo>();
internal readonly Dictionary<string, PropertyInfo> _longOptions = new Dictionary<string, PropertyInfo>();
Expand Down Expand Up @@ -100,7 +103,7 @@ internal CommandLineApplication(
Commands = new List<CommandLineApplication>();
RemainingArguments = new List<string>();
_helpTextGenerator = helpTextGenerator ?? throw new ArgumentNullException(nameof(helpTextGenerator));
Invoke = () => 0;
_action = DefaultAction;
_validationErrorHandler = DefaultValidationErrorHandler;
Out = context.Console.Out;
Error = context.Console.Error;
Expand Down Expand Up @@ -246,9 +249,24 @@ public CommandOption? OptionHelp
public bool IsShowingInformation { get; protected set; }

/// <summary>
/// <para>
/// This property has been marked as obsolete and will be removed in a future version.
/// The recommended replacement for setting this property is <see cref="OnExecute(Func{int})" />
/// and for invoking this property is <see cref="Execute(string[])" />.
/// </para>
/// <para>
/// The action to call when this command is matched and <see cref="IsShowingInformation"/> is <c>false</c>.
/// </para>
/// </summary>
public Func<int> Invoke { get; set; }
[Obsolete("This property has been marked as obsolete and will be removed in a future version. " +
"The recommended replacement for setting this property is OnExecute(Func<int>) " +
"and for invoking this property is Execute(string[] args).")]
[EditorBrowsable(EditorBrowsableState.Never)]
public Func<int> Invoke
{
get => () => _action(GetDefaultCancellationToken()).GetAwaiter().GetResult();
set => _action = _ => Task.FromResult(value());
}

/// <summary>
/// The long-form of the version to display in generated help text.
Expand Down Expand Up @@ -634,16 +652,22 @@ private void AddArgument(CommandArgument argument)
/// <param name="invoke"></param>
public void OnExecute(Func<int> invoke)
{
Invoke = invoke;
_action = _ => Task.FromResult(invoke());
}

/// <summary>
/// Defines an asynchronous callback.
/// </summary>
/// <param name="invoke"></param>
public void OnExecute(Func<Task<int>> invoke)
public void OnExecute(Func<Task<int>> invoke) => OnExecuteAsync(_ => invoke());

/// <summary>
/// Defines an asynchronous callback.
/// </summary>
/// <param name="invoke"></param>
public void OnExecuteAsync(Func<CancellationToken, Task<int>> invoke)
{
Invoke = () => invoke().GetAwaiter().GetResult();
_action = invoke;
}

/// <summary>
Expand Down Expand Up @@ -748,6 +772,29 @@ protected virtual void HandleParseResult(ParseResult parseResult)
/// <param name="args"></param>
/// <returns>The return code from <see cref="Invoke"/>.</returns>
public int Execute(params string[] args)
{
return ExecuteAsync(args).GetAwaiter().GetResult();
}

/// <summary>
/// Parses an array of strings using <see cref="Parse(string[])"/>.
/// <para>
/// If <see cref="OptionHelp"/> was matched, the generated help text is displayed in command line output.
/// </para>
/// <para>
/// If <see cref="OptionVersion"/> was matched, the generated version info is displayed in command line output.
/// </para>
/// <para>
/// If there were any validation errors produced from <see cref="GetValidationResult"/>, <see cref="ValidationErrorHandler"/> is invoked.
/// </para>
/// <para>
/// If the parse result matches this command, <see cref="Invoke"/> will be invoked.
/// </para>
/// </summary>
/// <param name="args"></param>
/// <param name="cancellationToken"></param>
/// <returns>The return code from <see cref="Invoke"/>.</returns>
public async Task<int> ExecuteAsync(string[] args, CancellationToken cancellationToken = default)
{
var parseResult = Parse(args);
var command = parseResult.SelectedCommand;
Expand All @@ -763,7 +810,12 @@ public int Execute(params string[] args)
return command.ValidationErrorHandler(validationResult);
}

return command.Invoke();
if (cancellationToken == CancellationToken.None)
{
cancellationToken = GetDefaultCancellationToken();
}

return await command._action(cancellationToken);
}

/// <summary>
Expand Down Expand Up @@ -881,7 +933,7 @@ public void ShowHelp(bool usePager)
/// The recommended replacement is <see cref="ShowHelp()" />.
/// </summary>
/// <param name="commandName">The subcommand for which to show help. Leave null to show for the current command.</param>
[Obsolete("This method has been marked as obsolete and will be removed in a future version." +
[Obsolete("This method has been marked as obsolete and will be removed in a future version. " +
"The recommended replacement is ShowHelp()")]
[EditorBrowsable(EditorBrowsableState.Never)]
public void ShowHelp(string? commandName = null)
Expand Down Expand Up @@ -928,7 +980,7 @@ public virtual string GetHelpText()
/// </summary>
/// <param name="commandName"></param>
/// <returns></returns>
[Obsolete("This method has been marked as obsolete and will be removed in a future version." +
[Obsolete("This method has been marked as obsolete and will be removed in a future version. " +
"The recommended replacement is GetHelpText()")]
[EditorBrowsable(EditorBrowsableState.Never)]
public virtual string GetHelpText(string? commandName = null)
Expand Down Expand Up @@ -1028,6 +1080,16 @@ internal bool MatchesName(string name)
return _names.Contains(name);
}

internal CancellationToken GetDefaultCancellationToken()
{
if (_context.Console is ICancellationTokenProvider ctp)
{
return ctp.Token;
}

return default;
}

private sealed class Builder : IConventionBuilder
{
private readonly CommandLineApplication _app;
Expand Down
14 changes: 13 additions & 1 deletion src/CommandLineUtils/IO/PhysicalConsole.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,29 @@

using System;
using System.IO;
using System.Threading;
using McMaster.Extensions.CommandLineUtils.Internal;

namespace McMaster.Extensions.CommandLineUtils
{
/// <summary>
/// An implementation of <see cref="IConsole"/> that wraps <see cref="System.Console"/>.
/// </summary>
public class PhysicalConsole : IConsole
public class PhysicalConsole : IConsole, ICancellationTokenProvider
{
private readonly CancellationTokenSource _cancelKeyPressed;

/// <summary>
/// A shared instance of <see cref="PhysicalConsole"/>.
/// </summary>
public static IConsole Singleton { get; } = new PhysicalConsole();

private PhysicalConsole()
{
_cancelKeyPressed = new CancellationTokenSource();
Console.CancelKeyPress += (_, __) => _cancelKeyPressed.Cancel();
}

/// <summary>
/// <see cref="Console.CancelKeyPress"/>.
/// </summary>
Expand Down Expand Up @@ -74,6 +84,8 @@ public ConsoleColor BackgroundColor
set => Console.BackgroundColor = value;
}

CancellationToken ICancellationTokenProvider.Token => _cancelKeyPressed.Token;

/// <summary>
/// <see cref="Console.ResetColor"/>.
/// </summary>
Expand Down
12 changes: 12 additions & 0 deletions src/CommandLineUtils/Internal/ICancellationTokenProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
// Copyright (c) Nate McMaster.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.

using System.Threading;

namespace McMaster.Extensions.CommandLineUtils.Internal
{
internal interface ICancellationTokenProvider
{
CancellationToken Token { get; }
}
}
Loading

0 comments on commit 431f273

Please sign in to comment.