Skip to content

Commit 431f273

Browse files
committed
feat: make CommandLineApplication asynchronous and add new async API
Fixes #208 Fixes #153 Fixes #225
1 parent 83b79da commit 431f273

File tree

11 files changed

+198
-35
lines changed

11 files changed

+198
-35
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,13 @@
99
* Fix [#221] by [@vpkopylov] - Use Pager for help text option only works on top-level help
1010
* PR [#239] by [@vpkopylov] - Add check for subcommand cycle
1111
* Support C# 8.0 and nullable reference types - [#245]
12+
* Add async methods to CommandLineApplication
13+
* Handle CTRL+C by default
14+
* Fix [#208] - make `CommandLineApplication.ExecuteAsync` actually asynchronous
15+
* Fix [#153] - add async methods that accept cancellation tokens
1216

17+
[#153]: https://github.com/natemcmaster/CommandLineUtils/issues/153
18+
[#208]: https://github.com/natemcmaster/CommandLineUtils/issues/208
1319
[#221]: https://github.com/natemcmaster/CommandLineUtils/issues/221
1420
[#227]: https://github.com/natemcmaster/CommandLineUtils/issues/227
1521
[#230]: https://github.com/natemcmaster/CommandLineUtils/pull/230

Directory.Build.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
<EmbedUntrackedSources>true</EmbedUntrackedSources>
2222
<PackageIconUrl>https://natemcmaster.github.io/CommandLineUtils/logo.png</PackageIconUrl>
2323
<NoPackageAnalysis>true</NoPackageAnalysis>
24+
<SuppressNETCoreSdkPreviewMessage>true</SuppressNETCoreSdkPreviewMessage>
2425
<WarningsNotAsErrors>$(WarningsNotAsErrors);1591</WarningsNotAsErrors>
2526
<LangVersion>8.0</LangVersion>
2627
<Nullable>enable</Nullable>

build.ps1

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ Remove-Item -Recurse $artifacts -ErrorAction Ignore
7272
exec dotnet msbuild /t:UpdateCiSettings @MSBuildArgs
7373
exec dotnet build --configuration $Configuration '-warnaserror:CS1591' @MSBuildArgs
7474
exec dotnet pack --no-restore --no-build --configuration $Configuration -o $artifacts @MSBuildArgs
75+
exec dotnet build --configuration $Configuration "$PSScriptRoot/docs/samples/samples.sln"
7576

7677
[string[]] $testArgs=@()
7778
if ($PSVersionTable.PSEdition -eq 'Core' -and -not $IsWindows) {

docs/samples/attributes/Program.cs

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.Net.Http;
66
using System.Text;
7+
using System.Threading;
78
using System.Threading.Tasks;
89
using McMaster.Extensions.CommandLineUtils;
910

@@ -36,7 +37,7 @@ class Program
3637

3738
private HttpClient _client;
3839

39-
private async Task<int> OnExecuteAsync(CommandLineApplication app)
40+
private async Task<int> OnExecuteAsync(CommandLineApplication app, CancellationToken cancellationToken = default)
4041
{
4142
if (string.IsNullOrEmpty(Url))
4243
{
@@ -57,10 +58,10 @@ private async Task<int> OnExecuteAsync(CommandLineApplication app)
5758
switch (RequestMethod)
5859
{
5960
case HttpMethod.Get:
60-
result = await GetAsync(uri);
61+
result = await GetAsync(uri, cancellationToken);
6162
break;
6263
case HttpMethod.Post:
63-
result = await PostAsync(uri);
64+
result = await PostAsync(uri, cancellationToken);
6465
break;
6566
default:
6667
throw new NotImplementedException();
@@ -80,15 +81,16 @@ private void LogTrace(TraceLevel level, string message)
8081
}
8182
}
8283

83-
private async Task<HttpResponseMessage> PostAsync(Uri uri)
84+
private async Task<HttpResponseMessage> PostAsync(Uri uri, CancellationToken cancellationToken)
8485
{
8586
var content = new ByteArrayContent(Encoding.ASCII.GetBytes(Data ?? string.Empty));
86-
return await _client.PostAsync(uri, content);
87+
return await _client.PostAsync(uri, content, cancellationToken);
8788
}
8889

89-
private async Task<HttpResponseMessage> GetAsync(Uri uri)
90+
private async Task<HttpResponseMessage> GetAsync(Uri uri, CancellationToken cancellationToken)
9091
{
91-
var result = await _client.GetAsync(uri);
92+
var result = await _client.GetAsync(uri, cancellationToken);
93+
cancellationToken.ThrowIfCancellationRequested();
9294
var content = await result.Content.ReadAsStringAsync();
9395

9496
Console.WriteLine(content);

src/CommandLineUtils/CommandLineApplication.Execute.cs

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System;
55
using System.IO;
66
using System.Linq;
7+
using System.Threading;
78
using System.Threading.Tasks;
89
using McMaster.Extensions.CommandLineUtils.Abstractions;
910
using McMaster.Extensions.CommandLineUtils.Internal;
@@ -28,6 +29,21 @@ partial class CommandLineApplication
2829
/// <returns>The process exit code</returns>
2930
public static int Execute<TApp>(CommandLineContext context)
3031
where TApp : class
32+
=> ExecuteAsync<TApp>(context).GetAwaiter().GetResult();
33+
34+
/// <summary>
35+
/// Creates an instance of <typeparamref name="TApp"/>, matching <see cref="CommandLineContext.Arguments"/>
36+
/// to all attributes on the type, and then invoking a method named "OnExecute" or "OnExecuteAsync" if it exists.
37+
/// See <seealso cref="OptionAttribute" />, <seealso cref="ArgumentAttribute" />,
38+
/// <seealso cref="HelpOptionAttribute"/>, and <seealso cref="VersionOptionAttribute"/>.
39+
/// </summary>
40+
/// <param name="context">The execution context.</param>
41+
/// <param name="cancellationToken"></param>
42+
/// <typeparam name="TApp">A type that should be bound to the arguments.</typeparam>
43+
/// <exception cref="InvalidOperationException">Thrown when attributes are incorrectly configured.</exception>
44+
/// <returns>The process exit code</returns>
45+
public static async Task<int> ExecuteAsync<TApp>(CommandLineContext context, CancellationToken cancellationToken = default)
46+
where TApp : class
3147
{
3248
if (context == null)
3349
{
@@ -54,7 +70,7 @@ public static int Execute<TApp>(CommandLineContext context)
5470
using var app = new CommandLineApplication<TApp>();
5571
app.SetContext(context);
5672
app.Conventions.UseDefaultConventions();
57-
return app.Execute(context.Arguments);
73+
return await app.ExecuteAsync(context.Arguments, cancellationToken);
5874
}
5975
catch (CommandParsingException ex)
6076
{
@@ -116,39 +132,44 @@ public static int Execute<TApp>(IConsole console, params string[] args)
116132
/// <returns>The process exit code</returns>
117133
public static Task<int> ExecuteAsync<TApp>(params string[] args)
118134
where TApp : class
119-
=> ExecuteAsync<TApp>(PhysicalConsole.Singleton, args);
135+
=> ExecuteAsync<TApp>(PhysicalConsole.Singleton, args);
120136

121137
/// <summary>
122138
/// Creates an instance of <typeparamref name="TApp"/>, matching <paramref name="args"/>
123139
/// to all attributes on the type, and then invoking a method named "OnExecute" or "OnExecuteAsync" if it exists.
124140
/// See <seealso cref="OptionAttribute" />, <seealso cref="ArgumentAttribute" />,
125141
/// <seealso cref="HelpOptionAttribute"/>, and <seealso cref="VersionOptionAttribute"/>.
126142
/// </summary>
127-
/// <param name="console">The console to use</param>
128143
/// <param name="args">The arguments</param>
144+
/// <param name="cancellationToken"></param>
129145
/// <typeparam name="TApp">A type that should be bound to the arguments.</typeparam>
130146
/// <exception cref="InvalidOperationException">Thrown when attributes are incorrectly configured.</exception>
131147
/// <returns>The process exit code</returns>
132-
public static Task<int> ExecuteAsync<TApp>(IConsole console, params string[] args)
133-
where TApp : class
148+
public static Task<int> ExecuteAsync<TApp>(string[] args, CancellationToken cancellationToken = default)
149+
where TApp : class
134150
{
135151
args ??= Util.EmptyArray<string>();
136-
var context = new DefaultCommandLineContext(console, Directory.GetCurrentDirectory(), args);
137-
return ExecuteAsync<TApp>(context);
152+
var context = new DefaultCommandLineContext(PhysicalConsole.Singleton, Directory.GetCurrentDirectory(), args);
153+
return ExecuteAsync<TApp>(context, cancellationToken);
138154
}
139155

140156
/// <summary>
141-
/// Creates an instance of <typeparamref name="TApp"/>, matching <see cref="CommandLineContext.Arguments"/>
157+
/// Creates an instance of <typeparamref name="TApp"/>, matching <paramref name="args"/>
142158
/// to all attributes on the type, and then invoking a method named "OnExecute" or "OnExecuteAsync" if it exists.
143159
/// See <seealso cref="OptionAttribute" />, <seealso cref="ArgumentAttribute" />,
144160
/// <seealso cref="HelpOptionAttribute"/>, and <seealso cref="VersionOptionAttribute"/>.
145161
/// </summary>
146-
/// <param name="context">The execution context.</param>
162+
/// <param name="console">The console to use</param>
163+
/// <param name="args">The arguments</param>
147164
/// <typeparam name="TApp">A type that should be bound to the arguments.</typeparam>
148165
/// <exception cref="InvalidOperationException">Thrown when attributes are incorrectly configured.</exception>
149166
/// <returns>The process exit code</returns>
150-
public static Task<int> ExecuteAsync<TApp>(CommandLineContext context)
167+
public static Task<int> ExecuteAsync<TApp>(IConsole console, params string[] args)
151168
where TApp : class
152-
=> Task.FromResult(Execute<TApp>(context));
169+
{
170+
args ??= Util.EmptyArray<string>();
171+
var context = new DefaultCommandLineContext(console, Directory.GetCurrentDirectory(), args);
172+
return ExecuteAsync<TApp>(context);
173+
}
153174
}
154175
}

src/CommandLineUtils/CommandLineApplication.cs

Lines changed: 70 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using System.Linq;
1010
using System.Reflection;
1111
using System.Text;
12+
using System.Threading;
1213
using System.Threading.Tasks;
1314
using McMaster.Extensions.CommandLineUtils.Abstractions;
1415
using McMaster.Extensions.CommandLineUtils.Conventions;
@@ -26,6 +27,8 @@ public partial class CommandLineApplication : IServiceProvider, IDisposable
2627
private const int HelpExitCode = 0;
2728
internal const int ValidationErrorExitCode = 1;
2829

30+
private static Task<int> DefaultAction(CancellationToken ct) => Task.FromResult(0);
31+
private Func<CancellationToken, Task<int>> _action;
2932
private List<Action<ParseResult>>? _onParsingComplete;
3033
internal readonly Dictionary<string, PropertyInfo> _shortOptions = new Dictionary<string, PropertyInfo>();
3134
internal readonly Dictionary<string, PropertyInfo> _longOptions = new Dictionary<string, PropertyInfo>();
@@ -100,7 +103,7 @@ internal CommandLineApplication(
100103
Commands = new List<CommandLineApplication>();
101104
RemainingArguments = new List<string>();
102105
_helpTextGenerator = helpTextGenerator ?? throw new ArgumentNullException(nameof(helpTextGenerator));
103-
Invoke = () => 0;
106+
_action = DefaultAction;
104107
_validationErrorHandler = DefaultValidationErrorHandler;
105108
Out = context.Console.Out;
106109
Error = context.Console.Error;
@@ -246,9 +249,24 @@ public CommandOption? OptionHelp
246249
public bool IsShowingInformation { get; protected set; }
247250

248251
/// <summary>
252+
/// <para>
253+
/// This property has been marked as obsolete and will be removed in a future version.
254+
/// The recommended replacement for setting this property is <see cref="OnExecute(Func{int})" />
255+
/// and for invoking this property is <see cref="Execute(string[])" />.
256+
/// </para>
257+
/// <para>
249258
/// The action to call when this command is matched and <see cref="IsShowingInformation"/> is <c>false</c>.
259+
/// </para>
250260
/// </summary>
251-
public Func<int> Invoke { get; set; }
261+
[Obsolete("This property has been marked as obsolete and will be removed in a future version. " +
262+
"The recommended replacement for setting this property is OnExecute(Func<int>) " +
263+
"and for invoking this property is Execute(string[] args).")]
264+
[EditorBrowsable(EditorBrowsableState.Never)]
265+
public Func<int> Invoke
266+
{
267+
get => () => _action(GetDefaultCancellationToken()).GetAwaiter().GetResult();
268+
set => _action = _ => Task.FromResult(value());
269+
}
252270

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

640658
/// <summary>
641659
/// Defines an asynchronous callback.
642660
/// </summary>
643661
/// <param name="invoke"></param>
644-
public void OnExecute(Func<Task<int>> invoke)
662+
public void OnExecute(Func<Task<int>> invoke) => OnExecuteAsync(_ => invoke());
663+
664+
/// <summary>
665+
/// Defines an asynchronous callback.
666+
/// </summary>
667+
/// <param name="invoke"></param>
668+
public void OnExecuteAsync(Func<CancellationToken, Task<int>> invoke)
645669
{
646-
Invoke = () => invoke().GetAwaiter().GetResult();
670+
_action = invoke;
647671
}
648672

649673
/// <summary>
@@ -748,6 +772,29 @@ protected virtual void HandleParseResult(ParseResult parseResult)
748772
/// <param name="args"></param>
749773
/// <returns>The return code from <see cref="Invoke"/>.</returns>
750774
public int Execute(params string[] args)
775+
{
776+
return ExecuteAsync(args).GetAwaiter().GetResult();
777+
}
778+
779+
/// <summary>
780+
/// Parses an array of strings using <see cref="Parse(string[])"/>.
781+
/// <para>
782+
/// If <see cref="OptionHelp"/> was matched, the generated help text is displayed in command line output.
783+
/// </para>
784+
/// <para>
785+
/// If <see cref="OptionVersion"/> was matched, the generated version info is displayed in command line output.
786+
/// </para>
787+
/// <para>
788+
/// If there were any validation errors produced from <see cref="GetValidationResult"/>, <see cref="ValidationErrorHandler"/> is invoked.
789+
/// </para>
790+
/// <para>
791+
/// If the parse result matches this command, <see cref="Invoke"/> will be invoked.
792+
/// </para>
793+
/// </summary>
794+
/// <param name="args"></param>
795+
/// <param name="cancellationToken"></param>
796+
/// <returns>The return code from <see cref="Invoke"/>.</returns>
797+
public async Task<int> ExecuteAsync(string[] args, CancellationToken cancellationToken = default)
751798
{
752799
var parseResult = Parse(args);
753800
var command = parseResult.SelectedCommand;
@@ -763,7 +810,12 @@ public int Execute(params string[] args)
763810
return command.ValidationErrorHandler(validationResult);
764811
}
765812

766-
return command.Invoke();
813+
if (cancellationToken == CancellationToken.None)
814+
{
815+
cancellationToken = GetDefaultCancellationToken();
816+
}
817+
818+
return await command._action(cancellationToken);
767819
}
768820

769821
/// <summary>
@@ -881,7 +933,7 @@ public void ShowHelp(bool usePager)
881933
/// The recommended replacement is <see cref="ShowHelp()" />.
882934
/// </summary>
883935
/// <param name="commandName">The subcommand for which to show help. Leave null to show for the current command.</param>
884-
[Obsolete("This method has been marked as obsolete and will be removed in a future version." +
936+
[Obsolete("This method has been marked as obsolete and will be removed in a future version. " +
885937
"The recommended replacement is ShowHelp()")]
886938
[EditorBrowsable(EditorBrowsableState.Never)]
887939
public void ShowHelp(string? commandName = null)
@@ -928,7 +980,7 @@ public virtual string GetHelpText()
928980
/// </summary>
929981
/// <param name="commandName"></param>
930982
/// <returns></returns>
931-
[Obsolete("This method has been marked as obsolete and will be removed in a future version." +
983+
[Obsolete("This method has been marked as obsolete and will be removed in a future version. " +
932984
"The recommended replacement is GetHelpText()")]
933985
[EditorBrowsable(EditorBrowsableState.Never)]
934986
public virtual string GetHelpText(string? commandName = null)
@@ -1028,6 +1080,16 @@ internal bool MatchesName(string name)
10281080
return _names.Contains(name);
10291081
}
10301082

1083+
internal CancellationToken GetDefaultCancellationToken()
1084+
{
1085+
if (_context.Console is ICancellationTokenProvider ctp)
1086+
{
1087+
return ctp.Token;
1088+
}
1089+
1090+
return default;
1091+
}
1092+
10311093
private sealed class Builder : IConventionBuilder
10321094
{
10331095
private readonly CommandLineApplication _app;

src/CommandLineUtils/IO/PhysicalConsole.cs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,29 @@
44

55
using System;
66
using System.IO;
7+
using System.Threading;
8+
using McMaster.Extensions.CommandLineUtils.Internal;
79

810
namespace McMaster.Extensions.CommandLineUtils
911
{
1012
/// <summary>
1113
/// An implementation of <see cref="IConsole"/> that wraps <see cref="System.Console"/>.
1214
/// </summary>
13-
public class PhysicalConsole : IConsole
15+
public class PhysicalConsole : IConsole, ICancellationTokenProvider
1416
{
17+
private readonly CancellationTokenSource _cancelKeyPressed;
18+
1519
/// <summary>
1620
/// A shared instance of <see cref="PhysicalConsole"/>.
1721
/// </summary>
1822
public static IConsole Singleton { get; } = new PhysicalConsole();
1923

24+
private PhysicalConsole()
25+
{
26+
_cancelKeyPressed = new CancellationTokenSource();
27+
Console.CancelKeyPress += (_, __) => _cancelKeyPressed.Cancel();
28+
}
29+
2030
/// <summary>
2131
/// <see cref="Console.CancelKeyPress"/>.
2232
/// </summary>
@@ -74,6 +84,8 @@ public ConsoleColor BackgroundColor
7484
set => Console.BackgroundColor = value;
7585
}
7686

87+
CancellationToken ICancellationTokenProvider.Token => _cancelKeyPressed.Token;
88+
7789
/// <summary>
7890
/// <see cref="Console.ResetColor"/>.
7991
/// </summary>
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Copyright (c) Nate McMaster.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Threading;
5+
6+
namespace McMaster.Extensions.CommandLineUtils.Internal
7+
{
8+
internal interface ICancellationTokenProvider
9+
{
10+
CancellationToken Token { get; }
11+
}
12+
}

0 commit comments

Comments
 (0)