Skip to content

Commit

Permalink
Support using CancellationToken with async commands.
Browse files Browse the repository at this point in the history
  • Loading branch information
SvenGroot committed Nov 29, 2023
1 parent 294485f commit bd6facc
Show file tree
Hide file tree
Showing 9 changed files with 273 additions and 85 deletions.
24 changes: 24 additions & 0 deletions src/Ookii.CommandLine.Tests/CommandTypes.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using Ookii.CommandLine.Commands;
using System;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;

#pragma warning disable OCL0033,OCL0034
Expand Down Expand Up @@ -96,6 +97,29 @@ public Task<int> RunAsync()
}
}

[Command(IsHidden = true)]
[Description("Async command description.")]
partial class AsyncCancelableCommand : IAsyncCancelableCommand
{
[CommandLineArgument(Position = 0)]
[Description("Argument description.")]
public int Value { get; set; }

public int Run()
{
// Do something different than RunAsync so the test can differentiate which one was
// called.
return Value + 1;
}

public async Task<int> RunAsync(CancellationToken cancellationToken)
{
await Task.Delay(Value, cancellationToken);
return 0;
}
}


// Used in stand-alone test, so not an actual command.
class AsyncBaseCommand : AsyncCommandBase
{
Expand Down
2 changes: 1 addition & 1 deletion src/Ookii.CommandLine.Tests/Ookii.CommandLine.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<AssemblyTitle>Ookii.CommandLine Unit Tests</AssemblyTitle>
<Description>Tests for Ookii.CommandLine.</Description>
<IsPackable>false</IsPackable>
<LangVersion>11.0</LangVersion>
<LangVersion>12.0</LangVersion>
<EmitCompilerGeneratedFiles>true</EmitCompilerGeneratedFiles>
<SignAssembly>true</SignAssembly>
<AssemblyOriginatorKeyFile>ookii.snk</AssemblyOriginatorKeyFile>
Expand Down
47 changes: 31 additions & 16 deletions src/Ookii.CommandLine.Tests/SubCommandTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;

namespace Ookii.CommandLine.Tests;
Expand All @@ -32,6 +33,7 @@ public void GetCommandsTest(ProviderKind kind)
VerifyCommands(
manager.GetCommands(),
new("AnotherSimpleCommand", typeof(AnotherSimpleCommand), false, "alias"),
new("AsyncCancelableCommand", typeof(AsyncCancelableCommand)),
new("AsyncCommand", typeof(AsyncCommand)),
new("custom", typeof(CustomParsingCommand), true),
new("HiddenCommand", typeof(HiddenCommand)),
Expand Down Expand Up @@ -103,13 +105,13 @@ public void CreateCommandTest(ProviderKind kind)
};

var manager = CreateManager(kind, options);
var command = (TestCommand?)manager.CreateCommand("test", new[] { "-Argument", "Foo" });
var command = (TestCommand?)manager.CreateCommand("test", ["-Argument", "Foo"]);
Assert.IsNotNull(command);
Assert.AreEqual(ParseStatus.Success, manager.ParseResult.Status);
Assert.AreEqual("Foo", command.Argument);
Assert.AreEqual("", writer.BaseWriter.ToString());

command = (TestCommand?)manager.CreateCommand(new[] { "test", "-Argument", "Bar" });
command = (TestCommand?)manager.CreateCommand(["test", "-Argument", "Bar"]);
Assert.IsNotNull(command);
Assert.AreEqual(ParseStatus.Success, manager.ParseResult.Status);
Assert.AreEqual("Bar", command.Argument);
Expand All @@ -121,26 +123,26 @@ public void CreateCommandTest(ProviderKind kind)
Assert.AreEqual(42, command2.Value);
Assert.AreEqual("", writer.BaseWriter.ToString());

var command3 = (CustomParsingCommand?)manager.CreateCommand(new[] { "custom", "hello" });
var command3 = (CustomParsingCommand?)manager.CreateCommand(["custom", "hello"]);
Assert.IsNotNull(command3);
// None because of custom parsing.
Assert.AreEqual(ParseStatus.None, manager.ParseResult.Status);
Assert.AreEqual("hello", command3.Value);
Assert.AreEqual("", writer.BaseWriter.ToString());

var versionCommand = manager.CreateCommand(new[] { "version" });
var versionCommand = manager.CreateCommand(["version"]);
Assert.IsNotNull(versionCommand);
Assert.AreEqual(ParseStatus.Success, manager.ParseResult.Status);
Assert.AreEqual("", writer.BaseWriter.ToString());

options.AutoVersionCommand = false;
versionCommand = manager.CreateCommand(new[] { "version" });
versionCommand = manager.CreateCommand(["version"]);
Assert.IsNull(versionCommand);
Assert.AreEqual(ParseStatus.None, manager.ParseResult.Status);
Assert.AreEqual(_expectedUsageNoVersion, writer.BaseWriter.ToString());

((StringWriter)writer.BaseWriter).GetStringBuilder().Clear();
versionCommand = manager.CreateCommand(new[] { "test", "-Foo" });
versionCommand = manager.CreateCommand(["test", "-Foo"]);
Assert.IsNull(versionCommand);
Assert.AreEqual(ParseStatus.Error, manager.ParseResult.Status);
Assert.AreEqual(CommandLineArgumentErrorCategory.UnknownArgument, manager.ParseResult.LastException!.Category);
Expand Down Expand Up @@ -265,7 +267,7 @@ public void TestCommandUsage(ProviderKind kind)

// This tests whether the command name is included in the help for the command.
var manager = CreateManager(kind, options);
var result = manager.CreateCommand(new[] { "AsyncCommand", "-Help" });
var result = manager.CreateCommand(["AsyncCommand", "-Help"]);
Assert.IsNull(result);
Assert.AreEqual(ParseStatus.Canceled, manager.ParseResult.Status);
Assert.AreEqual("Help", manager.ParseResult.ArgumentName);
Expand Down Expand Up @@ -330,18 +332,30 @@ public void TestCommandFilter(ProviderKind kind)
public async Task TestAsyncCommand(ProviderKind kind)
{
var manager = CreateManager(kind);
var result = await manager.RunCommandAsync(new[] { "AsyncCommand", "5" });
var result = await manager.RunCommandAsync(["AsyncCommand", "5"]);
Assert.AreEqual(5, result);

// RunCommand works but calls Run.
result = manager.RunCommand(new[] { "AsyncCommand", "5" });
result = manager.RunCommand(["AsyncCommand", "5"]);
Assert.AreEqual(6, result);

// RunCommandAsync works on non-async tasks.
result = await manager.RunCommandAsync(new[] { "AnotherSimpleCommand", "-Value", "5" });
result = await manager.RunCommandAsync(["AnotherSimpleCommand", "-Value", "5"]);
Assert.AreEqual(5, result);
}

[TestMethod]
[DynamicData(nameof(ProviderKinds), DynamicDataDisplayName = nameof(GetCustomDynamicDataDisplayName))]
public async Task TestAsyncCancelableCommand(ProviderKind kind)
{
var source = new CancellationTokenSource();
source.Cancel();
var manager = CreateManager(kind);
await Assert.ThrowsExceptionAsync<TaskCanceledException>(
async () => await manager.RunCommandAsync(["AsyncCancelableCommand", "10000"], source.Token));
}


[TestMethod]
public async Task TestAsyncCommandBase()
{
Expand All @@ -363,7 +377,7 @@ public void TestExplicitAssembly(ProviderKind kind)
// Using the calling assembly explicitly loads all the commands, including internal,
// same as the default constructor.
var mgr = new CommandManager(_commandAssembly);
Assert.AreEqual(7, mgr.GetCommands().Count());
Assert.AreEqual(8, mgr.GetCommands().Count());
}

// Explicitly specify the external assembly, which loads only public commands.
Expand All @@ -387,6 +401,7 @@ public void TestExplicitAssembly(ProviderKind kind)
VerifyCommands(
manager.GetCommands(),
new("AnotherSimpleCommand", typeof(AnotherSimpleCommand), false, "alias"),
new("AsyncCancelableCommand", typeof(AsyncCancelableCommand)),
new("AsyncCommand", typeof(AsyncCommand)),
new("custom", typeof(CustomParsingCommand), true),
new("external", typeof(ExternalCommand)),
Expand Down Expand Up @@ -426,7 +441,7 @@ public void TestParentCommand(ProviderKind kind)
Assert.IsNull(command);

manager.Options.ParentCommand = null;
var result = manager.RunCommand(new[] { "TestParentCommand", "TestChildCommand", "-Value", "5" });
var result = manager.RunCommand(["TestParentCommand", "TestChildCommand", "-Value", "5"]);
Assert.AreEqual(5, result);
}

Expand All @@ -448,17 +463,17 @@ public void TestParentCommandUsage(ProviderKind kind)
};

var manager = CreateManager(kind, options);
var result = manager.RunCommand(new[] { "TestParentCommand" });
var result = manager.RunCommand(["TestParentCommand"]);
Assert.AreEqual(1, result);
Assert.AreEqual(_expectedParentCommandUsage, writer.ToString());

((StringWriter)writer.BaseWriter).GetStringBuilder().Clear();
result = manager.RunCommand(new[] { "TestParentCommand", "NestedParentCommand" });
result = manager.RunCommand(["TestParentCommand", "NestedParentCommand"]);
Assert.AreEqual(1, result);
Assert.AreEqual(_expectedNestedParentCommandUsage, writer.ToString());

((StringWriter)writer.BaseWriter).GetStringBuilder().Clear();
result = manager.RunCommand(new[] { "TestParentCommand", "NestedParentCommand", "NestedParentChildCommand", "-Foo" });
result = manager.RunCommand(["TestParentCommand", "NestedParentCommand", "NestedParentChildCommand", "-Foo"]);
Assert.AreEqual(1, result);
Assert.AreEqual(_expectedNestedChildCommandUsage, writer.ToString());
}
Expand Down Expand Up @@ -596,6 +611,6 @@ public static IEnumerable<object[]> ProviderKinds
=> new[]
{
new object[] { ProviderKind.Reflection },
new object[] { ProviderKind.Generated }
[ProviderKind.Generated]
};
}
37 changes: 37 additions & 0 deletions src/Ookii.CommandLine/Commands/AsyncCancelableCommandBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
using System.Threading;
using System.Threading.Tasks;

namespace Ookii.CommandLine.Commands;

/// <summary>
/// Base class for asynchronous commands with cancellation support that want the
/// <see cref="ICommand.Run" qualifyHint="true"/> method to invoke the
/// <see cref="IAsyncCancelableCommand.RunAsync" qualifyHint="true"/> method.
/// </summary>
/// <remarks>
/// <para>
/// This class is provided for convenience for creating asynchronous commands without having to
/// implement the <see cref="ICommand.Run" qualifyHint="true"/> method.
/// </para>
/// <para>
/// This class implements the <see cref="IAsyncCancelableCommand"/> interface, which can use the
/// cancellation token passed to the <see cref="CommandManager.RunCommandAsync(CancellationToken)" qualifyHint="true"/>
/// method.
/// </para>
/// <para>
/// If you do not need the cancellation token, you can implement the <see cref="IAsyncCommand"/>
/// interface or derive from the <see cref="AsyncCommandBase"/> class instead.
/// </para>
/// </remarks>
/// <threadsafety static="true" instance="false"/>
public abstract class AsyncCancelableCommandBase : IAsyncCancelableCommand
{
/// <summary>
/// Calls the <see cref="RunAsync"/> method and waits synchronously for it to complete.
/// </summary>
/// <returns>The exit code of the command.</returns>
public virtual int Run() => Task.Run(() => RunAsync(default)).ConfigureAwait(false).GetAwaiter().GetResult();

/// <inheritdoc/>
public abstract Task<int> RunAsync(CancellationToken token);
}
11 changes: 7 additions & 4 deletions src/Ookii.CommandLine/Commands/AsyncCommandBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ namespace Ookii.CommandLine.Commands;
/// This class is provided for convenience for creating asynchronous commands without having to
/// implement the <see cref="ICommand.Run" qualifyHint="true"/> method.
/// </para>
/// <para>
/// If you want to use the cancellation token passed to the
/// <see cref="CommandManager.RunCommandAsync(System.Threading.CancellationToken)" qualifyHint="true"/>
/// method, you should instead implement the <see cref="IAsyncCancelableCommand"/> interface or
/// derive from the <see cref="AsyncCancelableCommandBase"/> class.
/// </para>
/// </remarks>
/// <threadsafety static="true" instance="false"/>
public abstract class AsyncCommandBase : IAsyncCommand
Expand All @@ -19,10 +25,7 @@ public abstract class AsyncCommandBase : IAsyncCommand
/// Calls the <see cref="RunAsync"/> method and waits synchronously for it to complete.
/// </summary>
/// <returns>The exit code of the command.</returns>
public virtual int Run()
{
return Task.Run(RunAsync).ConfigureAwait(false).GetAwaiter().GetResult();
}
public virtual int Run() => Task.Run(RunAsync).ConfigureAwait(false).GetAwaiter().GetResult();

/// <inheritdoc/>
public abstract Task<int> RunAsync();
Expand Down
Loading

0 comments on commit bd6facc

Please sign in to comment.