GithubHelp home page GithubHelp logo

cysharp / consoleappframework Goto Github PK

View Code? Open in Web Editor NEW
1.6K 41.0 93.0 4.57 MB

Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe CLI Framework powered by C# Source Generator.

License: MIT License

C# 100.00%

consoleappframework's Introduction

ConsoleAppFramework

GitHub Actions Releases

ConsoleAppFramework v5 is Zero Dependency, Zero Overhead, Zero Reflection, Zero Allocation, AOT Safe CLI Framework powered by C# Source Generator; achieves exceptionally high performance and minimal binary size. Leveraging the latest features of .NET 8 and C# 12 (IncrementalGenerator, managed function pointer, params arrays and default values lambda expression, ISpanParsable<T>, PosixSignalRegistration, etc.), this library ensures maximum performance while maintaining flexibility and extensibility.

image

Set RunStrategy=ColdStart WarmupCount=0 to calculate the cold start benchmark, which is suitable for CLI application.

The magical performance is achieved by statically generating everything and parsing inline. Let's take a look at a minimal example:

using ConsoleAppFramework;

// args: ./cmd --foo 10 --bar 20
ConsoleApp.Run(args, (int foo, int bar) => Console.WriteLine($"Sum: {foo + bar}"));

Unlike typical Source Generators that use attributes as keys for generation, ConsoleAppFramework analyzes the provided lambda expressions or method references and generates the actual code body of the Run method.

internal static partial class ConsoleApp
{
    // Generate the Run method itself with arguments and body to match the lambda expression
    public static void Run(string[] args, Action<int, int> command)
    {
        // code body
    }
}
Full generated source code
namespace ConsoleAppFramework;

internal static partial class ConsoleApp
{
    public static void Run(string[] args, Action<int, int> command)
    {
        if (TryShowHelpOrVersion(args, 2, -1)) return;

        var arg0 = default(int);
        var arg0Parsed = false;
        var arg1 = default(int);
        var arg1Parsed = false;

        try
        {
            for (int i = 0; i < args.Length; i++)
            {
                var name = args[i];

                switch (name)
                {
                    case "--foo":
                    {
                        if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); }
                        arg0Parsed = true;
                        break;
                    }
                    case "--bar":
                    {
                        if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg1)) { ThrowArgumentParseFailed("bar", args[i]); }
                        arg1Parsed = true;
                        break;
                    }
                    default:
                        if (string.Equals(name, "--foo", StringComparison.OrdinalIgnoreCase))
                        {
                            if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg0)) { ThrowArgumentParseFailed("foo", args[i]); }
                            arg0Parsed = true;
                            break;
                        }
                        if (string.Equals(name, "--bar", StringComparison.OrdinalIgnoreCase))
                        {
                            if (!TryIncrementIndex(ref i, args.Length) || !int.TryParse(args[i], out arg1)) { ThrowArgumentParseFailed("bar", args[i]); }
                            arg1Parsed = true;
                            break;
                        }
                        ThrowArgumentNameNotFound(name);
                        break;
                }
            }
            if (!arg0Parsed) ThrowRequiredArgumentNotParsed("foo");
            if (!arg1Parsed) ThrowRequiredArgumentNotParsed("bar");

            command(arg0!, arg1!);
        }
        catch (Exception ex)
        {
            Environment.ExitCode = 1;
            if (ex is ValidationException or ArgumentParseFailedException)
            {
                LogError(ex.Message);
            }
            else
            {
                LogError(ex.ToString());
            }
        }
    }

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    static bool TryIncrementIndex(ref int index, int length)
    {
        if (index < length)
        {
            index++;
            return true;
        }
        return false;
    }

    static partial void ShowHelp(int helpId)
    {
        Log("""
Usage: [options...] [-h|--help] [--version]

Options:
  --foo <int>     (Required)
  --bar <int>     (Required)
""");
    }
}

As you can see, the code is straightforward and simple, making it easy to imagine the execution cost of the framework portion. That's right, it's zero. This technique was influenced by Rust's macros. Rust has Attribute-like macros and Function-like macros, and ConsoleAppFramework's generation can be considered as Function-like macros.

The ConsoleApp class, along with everything else, is generated entirely by the Source Generator, resulting in no dependencies, including ConsoleAppFramework itself. This characteristic should contribute to the small assembly size and ease of handling, including support for Native AOT.

Moreover, CLI applications typically involve single-shot execution from a cold start. As a result, common optimization techniques such as dynamic code generation (IL Emit, ExpressionTree.Compile) and caching (ArrayPool) do not work effectively. ConsoleAppFramework generates everything statically in advance, achieving performance equivalent to optimized hand-written code without reflection or boxing.

ConsoleAppFramework offers a rich set of features as a framework. The Source Generator analyzes which modules are being used and generates the minimal code necessary to implement the desired functionality.

  • SIGINT/SIGTERM(Ctrl+C) handling with gracefully shutdown via CancellationToken
  • Filter(middleware) pipeline to intercept before/after execution
  • Exit code management
  • Support for async commands
  • Registration of multiple commands
  • Registration of nested commands
  • Setting option aliases and descriptions from code document comment
  • System.ComponentModel.DataAnnotations attribute-based Validation
  • Dependency Injection for command registration by type and public methods
  • Microsoft.Extensions(Logging, Configuration, etc...) integration
  • High performance value parsing via ISpanParsable<T>
  • Parsing of params arrays
  • Parsing of JSON arguments
  • Help(-h|--help) option builder
  • Default show version(--version) option

As you can see from the generated output, the help display is also fast. In typical frameworks, the help string is constructed after the help invocation. However, in ConsoleAppFramework, the help is embedded as string constants, achieving the absolute maximum performance that cannot be surpassed!

Getting Started

This library is distributed via NuGet, minimal requirement is .NET 8 and C# 12.

dotnet add package ConsoleAppFramework

ConsoleAppFramework is an analyzer (Source Generator) and does not have any dll references. When referenced, the entry point class ConsoleAppFramework.ConsoleApp is generated internally.

The first argument of Run or RunAsync can be string[] args, and the second argument can be any lambda expression, method, or function reference. Based on the content of the second argument, the corresponding function is automatically generated.

using ConsoleAppFramework;

ConsoleApp.Run(args, (string name) => Console.WriteLine($"Hello {name}"));

You can execute command like sampletool --name "foo".

  • The return value can be void, int, Task, or Task<int>
    • If an int is returned, that value will be set to Environment.ExitCode
  • By default, option argument names are converted to --lower-kebab-case
    • For example, jsonValue becomes --json-value
    • Option argument names are case-insensitive, but lower-case matches faster

When passing a method, you can write it as follows:

ConsoleApp.Run(args, Sum);

void Sum(int x, int y) => Console.Write(x + y);

Additionally, for static functions, you can pass them as function pointers. In that case, the managed function pointer arguments will be generated, resulting in maximum performance.

unsafe
{
    ConsoleApp.Run(args, &Sum);
}

static void Sum(int x, int y) => Console.Write(x + y);
public static unsafe void Run(string[] args, delegate* managed<int, int, void> command)

Unfortunately, currently static lambdas cannot be assigned to function pointers, so defining a named function is necessary.

When defining an asynchronous method using a lambda expression, the async keyword is required.

// --foo, --bar
await ConsoleApp.RunAsync(args, async (int foo, int bar, CancellationToken cancellationToken) =>
{
    await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
    Console.WriteLine($"Sum: {foo + bar}");
});

You can use either the Run or RunAsync method for invocation. It is optional to use CancellationToken as an argument. This becomes a special parameter and is excluded from the command options. Internally, it uses PosixSignalRegistration to handle SIGINT, SIGTERM, and SIGKILL. When these signals are invoked (e.g., Ctrl+C), the CancellationToken is set to CancellationRequested. If CancellationToken is not used as an argument, these signals will not be handled, and the program will terminate immediately. For more details, refer to the CancellationToken and Gracefully Shutdown section.

Option aliases and Help, Version

By default, if -h or --help is provided, or if no arguments are passed, the help display will be invoked.

ConsoleApp.Run(args, (string message) => Console.Write($"Hello, {message}"));
Usage: [options...] [-h|--help] [--version]

Options:
  --message <string>     (Required)

In ConsoleAppFramework, instead of using attributes, you can provide descriptions and aliases for functions by writing Document Comments. This avoids the common issue in frameworks where arguments become cluttered with attributes, making the code difficult to read. With this approach, a natural writing style is achieved.

ConsoleApp.Run(args, Commands.Hello);

static class Commands
{
    /// <summary>
    /// Display Hello.
    /// </summary>
    /// <param name="message">-m, Message to show.</param>
    public static void Hello(string message) => Console.Write($"Hello, {message}");
}
Usage: [options...] [-h|--help] [--version]

Display Hello.

Options:
  -m|--message <string>    Message to show. (Required)

To add aliases to parameters, list the aliases separated by | before the comma in the comment. For example, if you write a comment like -a|-b|--abcde, Description., then -a, -b, and --abcde will be treated as aliases, and Description. will be the description.

Unfortunately, due to current C# specifications, lambda expressions and local functions do not support document comments, so a class is required.

In addition to -h|--help, there is another special built-in option: --version. In default, it displays the AssemblyInformationalVersion or AssemblyVersion. You can configure version string by ConsoleApp.Version, for example ConsoleApp.Version = "2001.9.3f14-preview2";.

Command

If you want to register multiple commands or perform complex operations (such as adding filters), instead of using Run/RunAsync, obtain the ConsoleAppBuilder using ConsoleApp.Create(). Call Add, Add<T>, or UseFilter<T> multiple times on the ConsoleAppBuilder to register commands and filters, and finally execute the application using Run or RunAsync.

var app = ConsoleApp.Create();

app.Add("", (string msg) => Console.WriteLine(msg));
app.Add("echo", (string msg) => Console.WriteLine(msg));
app.Add("sum", (int x, int y) => Console.WriteLine(x + y));

// --msg
// echo --msg
// sum --x --y
app.Run(args);

The first argument of Add is the command name. If you specify an empty string "", it becomes the root command. Unlike parameters, command names are case-sensitive and cannot have multiple names.

With Add<T>, you can add multiple commands at once using a class-based approach, where public methods are treated as commands. If you want to write document comments for multiple commands, this approach allows for cleaner code, so it is recommended. Additionally, as mentioned later, you can also write clean code for Dependency Injection (DI) using constructor injection.

var app = ConsoleApp.Create();
app.Add<MyCommands>();
app.Run(args);

public class MyCommands
{
    /// <summary>Root command test.</summary>
    /// <param name="msg">-m, Message to show.</param>
    [Command("")]
    public void Root(string msg) => Console.WriteLine(msg);

    /// <summary>Display message.</summary>
    /// <param name="msg">Message to show.</param>
    public void Echo(string msg) => Console.WriteLine(msg);

    /// <summary>Sum parameters.</summary>
    /// <param name="x">left value.</param>
    /// <param name="y">right value.</param>
    public void Sum(int x, int y) => Console.WriteLine(x + y);
}

When you check the registered commands with --help, it will look like this. Note that you can register multiple Add<T> and also add commands using Add.

Usage: [command] [options...] [-h|--help] [--version]

Root command test.

Options:
  -m|--msg <string>    Message to show. (Required)

Commands:
  echo    Display message.
  sum     Sum parameters.

By default, the command name is derived from the method name converted to lower-kebab-case. However, you can change the name to any desired value using the [Command(string commandName)] attribute.

If the class implements IDisposable or IAsyncDisposable, the Dispose or DisposeAsync method will be called after the command execution.

Nested command

You can create a deep command hierarchy by adding commands with paths separated by space( ) when registering them. This allows you to add commands at nested levels.

var app = ConsoleApp.Create();

app.Add("foo", () => { });
app.Add("foo bar", () => { });
app.Add("foo bar barbaz", () => { });
app.Add("foo baz", () => { });

// Commands:
//   foo
//   foo bar
//   foo bar barbaz
//   foo baz
app.Run(args);

Add<T> can also add commands to a hierarchy by passing a string commandPath argument.

var app = ConsoleApp.Create();
app.Add<MyCommands>("foo");

// Commands:
//  foo         Root command test.
//  foo echo    Display message.
//  foo sum     Sum parameters.
app.Run(args);

Performance of Commands

In ConsoleAppFramework, the number and types of registered commands are statically determined at compile time. For example, let's register the following four commands:

app.Add("foo", () => { });
app.Add("foo bar", (int x, int y) => { });
app.Add("foo bar barbaz", (DateTime dateTime) => { });
app.Add("foo baz", async (string foo = "test", CancellationToken cancellationToken = default) => { });

The Source Generator generates four fields and holds them with specific types.

partial struct ConsoleAppBuilder
{
    Action command0 = default!;
    Action<int, int> command1 = default!;
    Action<global::System.DateTime> command2 = default!;
    Func<string, global::System.Threading.CancellationToken, Task> command3 = default!;

    partial void AddCore(string commandName, Delegate command)
    {
        switch (commandName)
        {
            case "foo":
                this.command0 = Unsafe.As<Action>(command);
                break;
            case "foo bar":
                this.command1 = Unsafe.As<Action<int, int>>(command);
                break;
            case "foo bar barbaz":
                this.command2 = Unsafe.As<Action<global::System.DateTime>>(command);
                break;
            case "foo baz":
                this.command3 = Unsafe.As<Func<string, global::System.Threading.CancellationToken, Task>>(command);
                break;
            default:
                break;
        }
    }
}

This ensures the fastest execution speed without any additional unnecessary allocations such as arrays and without any boxing since it holds static delegate types.

Command routing also generates a switch of nested string constants.

partial void RunCore(string[] args)
{
    if (args.Length == 0)
    {
        ShowHelp(-1);
        return;
    }
    switch (args[0])
    {
        case "foo":
            if (args.Length == 1)
            {
                RunCommand0(args, args.AsSpan(1), command0);
                return;
            }
            switch (args[1])
            {
                case "bar":
                    if (args.Length == 2)
                    {
                        RunCommand1(args, args.AsSpan(2), command1);
                        return;
                    }
                    switch (args[2])
                    {
                        case "barbaz":
                            RunCommand2(args, args.AsSpan(3), command2);
                            break;
                        default:
                            RunCommand1(args, args.AsSpan(2), command1);
                            break;
                    }
                    break;
                case "baz":
                    RunCommand3(args, args.AsSpan(2), command3);
                    break;
                default:
                    RunCommand0(args, args.AsSpan(1), command0);
                    break;
            }
            break;
        default:
            ShowHelp(-1);
            break;
    }
}

The C# compiler performs complex generation for string constant switches, making them extremely fast, and it would be difficult to achieve faster routing than this.

Parse and Value Binding

The method parameter names and types determine how to parse and bind values from the command-line arguments. When using lambda expressions, optional values and params arrays supported from C# 12 are also supported.

ConsoleApp.Run(args, (
    [Argument]DateTime dateTime,  // Argument
    [Argument]Guid guidvalue,     // 
    int intVar,                   // required
    bool boolFlag,                // flag
    MyEnum enumValue,             // enum
    int[] array,                  // array
    MyClass obj,                  // object
    string optional = "abcde",    // optional
    double? nullableValue = null, // nullable
    params string[] paramsArray   // params
    ) => { });

When using ConsoleApp.Run, you can check the syntax of the command line in the tooltip to see how it is generated.

image

For the rules on converting parameter names to option names, aliases, and how to set documentation, refer to the Option aliases section.

Parameters marked with the [Argument] attribute receive values in order without parameter names. This attribute can only be set on sequential parameters from the beginning.

To convert from string arguments to various types, basic primitive types (string, char, sbyte, byte, short, int, long, uint, ushort, ulong, decimal, float, double) use TryParse. For types that implement ISpanParsable<T> (DateTime, DateTimeOffset, Guid, BigInteger, Complex, Half, Int128, etc.), IParsable.TryParse or ISpanParsable.TryParse is used.

For enum, it is parsed using Enum.TryParse(ignoreCase: true).

bool is treated as a flag and is always optional. It becomes true when the parameter name is passed.

Array

Array parsing has three special patterns.

For a regular T[], if the value starts with [, it is parsed using JsonSerializer.Deserialize. Otherwise, it is parsed as comma-separated values. For example, [1,2,3] or 1,2,3 are allowed as values. To set an empty array, pass [].

For params T[], all subsequent arguments become the values of the array. For example, if there is an input like --paramsArray foo bar baz, it will be bound to a value like ["foo", "bar", "baz"].

Object

If none of the above cases apply, JsonSerializer.Deserialize<T> is used to perform binding as JSON. However, CancellationToken and ConsoleAppContext are treated as special types and excluded from binding. Also, parameters with the [FromServices] attribute are not subject to binding.

If you want to change the deserialization options, you can set JsonSerializerOptions to ConsoleApp.JsonSerializerOptions.

Custom Value Converter

To perform custom binding to existing types that do not support ISpanParsable<T>, you can create and set up a custom parser. For example, if you want to pass System.Numerics.Vector3 as a comma-separated string like 1.3,4.12,5.947 and parse it, you can create an Attribute with AttributeTargets.Parameter that implements IArgumentParser<T>'s static bool TryParse(ReadOnlySpan<char> s, out Vector3 result) as follows:

[AttributeUsage(AttributeTargets.Parameter)]
public class Vector3ParserAttribute : Attribute, IArgumentParser<Vector3>
{
    public static bool TryParse(ReadOnlySpan<char> s, out Vector3 result)
    {
        Span<Range> ranges = stackalloc Range[3];
        var splitCount = s.Split(ranges, ',');
        if (splitCount != 3)
        {
            result = default;
            return false;
        }

        float x;
        float y;
        float z;
        if (float.TryParse(s[ranges[0]], out x) && float.TryParse(s[ranges[1]], out y) && float.TryParse(s[ranges[2]], out z))
        {
            result = new Vector3(x, y, z);
            return true;
        }

        result = default;
        return false;
    }
}

By setting this attribute on a parameter, the custom parser will be called when parsing the args.

ConsoleApp.Run(args, ([Vector3Parser] Vector3 position) => Console.WriteLine(position));

Syntax Parsing Policy and Performance

While there are some standards for command-line arguments, such as UNIX tools and POSIX, there is no absolute specification. The Command-line syntax overview for System.CommandLine provides an explanation of the specifications adopted by System.CommandLine. However, ConsoleAppFramework, while referring to these specifications to some extent, does not necessarily aim to fully comply with them.

For example, specifications that change behavior based on -x and -X or allow bundling -f -d -x as -fdx are not easy to understand and also take time to parse. The poor performance of System.CommandLine may be influenced by its adherence to complex grammar. Therefore, ConsoleAppFramework prioritizes performance and clear rules. It uses lower-kebab-case as the basis while allowing case-insensitive matching. It does not support ambiguous grammar that cannot be processed in a single pass or takes time to parse.

System.CommandLine seems to be aiming for a new direction in .NET 9 and .NET 10, but from a performance perspective, it will never surpass ConsoleAppFramework.

CancellationToken(Gracefully Shutdown) and Timeout

In ConsoleAppFramework, when you pass a CancellationToken as an argument, it can be used to check for interruption commands (SIGINT/SIGTERM/SIGKILL - Ctrl+C) rather than being treated as a parameter. For handling this, ConsoleAppFramework performs special code generation when a CancellationToken is included in the parameters.

using var posixSignalHandler = PosixSignalHandler.Register(ConsoleApp.Timeout);
var arg0 = posixSignalHandler.Token;

await Task.Run(() => command(arg0!)).WaitAsync(posixSignalHandler.TimeoutToken);

If a CancellationToken is not passed, the application is immediately forced to terminate when an interruption command (Ctrl+C) is received. However, if a CancellationToken is present, it internally uses PosixSignalRegistration to hook SIGINT/SIGTERM/SIGKILL and sets the CancellationToken to a canceled state. Additionally, it prevents forced termination to allow for a graceful shutdown.

If the CancellationToken is handled correctly, the application can perform proper termination processing based on the application's handling. However, if the CancellationToken is mishandled, the application may not terminate even when an interruption command is received. To avoid this, a timeout timer starts after the interruption command, and the application is forcibly terminated again after the specified time.

The default timeout is 5 seconds, but it can be changed using ConsoleApp.Timeout. For example, setting it to ConsoleApp.Timeout = Timeout.InfiniteTimeSpan; disables the forced termination caused by the timeout.

The hooking behavior using PosixSignalRegistration is determined by the presence of a CancellationToken (or always takes effect if a filter is set). Therefore, even for synchronous methods, it is possible to change the behavior by including a CancellationToken as an argument.

Exit Code

If the method returns int or Task<int>, ConsoleAppFramework will set the return value to the exit code. Due to the nature of code generation, when writing lambda expressions, you need to explicitly specify either int or Task<int>.

// return Random ExitCode...
ConsoleApp.Run(args, int () => Random.Shared.Next());
// return StatusCode
await ConsoleApp.RunAsync(args, async Task<int> (string url, CancellationToken cancellationToken) =>
{
    using var client = new HttpClient();
    var response = await client.GetAsync(url, cancellationToken);
    return (int)response.StatusCode;
});

If the method throws an unhandled exception, ConsoleAppFramework always set 1 to the exit code. Also, in that case, output Exception.ToString to ConsoleApp.LogError (the default is Console.WriteLine). If you want to modify this code, please create a custom filter. For more details, refer to the Filter section.

Attribute based parameters validation

ConsoleAppFramework performs validation when the parameters are marked with attributes for validation from System.ComponentModel.DataAnnotations (more precisely, attributes that implement ValidationAttribute). The validation occurs after parameter binding and before command execution. If the validation fails, it throws a ValidationException.

ConsoleApp.Run(args, ([EmailAddress] string firstArg, [Range(0, 2)] int secondArg) => { });

For example, if you pass arguments like args = "--first-arg invalid.email --second-arg 10".Split(' ');, you will see validation failure messages such as:

The firstArg field is not a valid e-mail address.
The field secondArg must be between 0 and 2.

By default, the ExitCode is set to 1 in this case.

Filter(Middleware) Pipline / ConsoleAppContext

Filters are provided as a mechanism to hook into the execution before and after. To use filters, define an internal class that implements ConsoleAppFilter.

internal class NopFilter(ConsoleAppFilter next) : ConsoleAppFilter(next) // ctor needs `ConsoleAppFilter next` and call base(next)
{
    // implement InvokeAsync as filter body
    public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
    {
        try
        {
            /* on before */
            await Next.InvokeAsync(context, cancellationToken); // invoke next filter or command body
            /* on after */
        }
        catch
        {
            /* on error */
            throw;
        }
        finally
        {
            /* on finally */
        }
    }
}

Filters can be attached multiple times to "global", "class", or "method" using UseFilter<T> or [ConsoleAppFilter<T>]. The order of filters is global → class → method, and the execution order is determined by the definition order from top to bottom.

var app = ConsoleApp.Create();

// global filters
app.UseFilter<NopFilter>(); //order 1
app.UseFilter<NopFilter>(); //order 2

app.Add<MyCommand>();
app.Run(args);

// per class filters
[ConsoleAppFilter<NopFilter>] // order 3
[ConsoleAppFilter<NopFilter>] // order 4
public class MyCommand
{
    // per method filters
    [ConsoleAppFilter<NopFilter>] // order 5
    [ConsoleAppFilter<NopFilter>] // order 6
    public void Echo(string msg) => Console.WriteLine(msg);
}

Filters allow various processes to be shared. For example, the process of measuring execution time can be written as follows:

internal class LogRunningTimeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next)
{
    public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
    {
        var startTime = Stopwatch.GetTimestamp();
        ConsoleApp.Log($"Execute command at {DateTime.UtcNow.ToLocalTime()}"); // LocalTime for human readable time
        try
        {
            await Next.InvokeAsync(context, cancellationToken);
            ConsoleApp.Log($"Command execute successfully at {DateTime.UtcNow.ToLocalTime()}, Elapsed: " + (Stopwatch.GetElapsedTime(startTime)));
        }
        catch
        {
            ConsoleApp.Log($"Command execute failed at {DateTime.UtcNow.ToLocalTime()}, Elapsed: " + (Stopwatch.GetElapsedTime(startTime)));
            throw;
        }
    }
}

In case of an exception, the ExitCode is usually 1, and the stack trace is also displayed. However, by applying an exception handling filter, the behavior can be changed.

internal class ChangeExitCodeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next)
{
    public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
    {
        try
        {
            await Next.InvokeAsync(context, cancellationToken);
        }
        catch (Exception ex)
        {
            if (ex is OperationCanceledException) return;

            Environment.ExitCode = 9999; // change custom exit code
            ConsoleApp.LogError(ex.Message); // .ToString() shows stacktrace, .Message can avoid showing stacktrace to user.
        }
    }
}

Filters are executed after the command name routing is completed. If you want to prohibit multiple executions for each command name, you can use ConsoleAppContext.CommandName as the key.

internal class PreventMultipleSameCommandInvokeFilter(ConsoleAppFilter next) : ConsoleAppFilter(next)
{
    public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
    {
        var basePath = Assembly.GetEntryAssembly()?.Location.Replace(Path.DirectorySeparatorChar, '_');
        var mutexKey = $"{basePath}$$${context.CommandName}"; // lock per command-name

        using var mutex = new Mutex(true, mutexKey, out var createdNew);
        if (!createdNew)
        {
            throw new Exception($"already running command:{context.CommandName} in another process.");
        }

        await Next.InvokeAsync(context, cancellationToken);
    }
}

If you want to pass values between filters or to commands, you can use ConsoleAppContext.State. For example, if you want to perform authentication processing and pass around the ID, you can write code like the following. Since ConsoleAppContext is an immutable record, you need to pass the rewritten context to Next using the with syntax.

internal class AuthenticationFilter(ConsoleAppFilter next) : ConsoleAppFilter(next)
{
    public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
    {
        var requestId = Guid.NewGuid();
        var userId = await GetUserIdAsync();

        // setup new state to context
        var authedContext = context with { State = new ApplicationContext(requestId, userId) };
        await Next.InvokeAsync(authedContext, cancellationToken);
    }

    // get user-id from DB/auth saas/others
    async Task<int> GetUserIdAsync()
    {
        await Task.Delay(TimeSpan.FromSeconds(1));
        return 1999;
    }
}

record class ApplicationContext(Guid RequiestId, int UserId);

Commands can accept ConsoleAppContext as an argument. This allows using the values processed by filters.

var app = ConsoleApp.Create();

app.UseFilter<AuthenticationFilter>();

app.Add("", (int x, int y, ConsoleAppContext context) =>
{
    var appContext = (ApplicationContext)context.State!;
    var requestId = appContext.RequiestId;
    var userId = appContext.UserId;

    Console.WriteLine($"Request:{requestId} User:{userId} Sum:{x + y}");
});

app.Run(args);

ConsoleAppContext also has a ConsoleAppContext.Arguments property that allows you to obtain the (string[] args) passed to Run/RunAsync.

Sharing Filters Between Projects

ConsoleAppFilter is defined as internal for each project by the Source Generator. Therefore, an additional library is provided for referencing common filter definitions across projects.

PM> Install-Package ConsoleAppFramework.Abstractions

This library includes the following classes:

  • IArgumentParser<T>
  • ConsoleAppContext
  • ConsoleAppFilter
  • ConsoleAppFilterAttribute<T>

Internally, when referencing ConsoleAppFramework.Abstractions, the USE_EXTERNAL_CONSOLEAPP_ABSTRACTIONS compilation symbol is added. This disables the above classes generated by the Source Generator, and prioritizes using the classes within the library.

Performance of filter

In general frameworks, filters are dynamically added at runtime, resulting in a variable number of filters. Therefore, they need to be allocated using a dynamic array. In ConsoleAppFramework, the number of filters is statically determined at compile time, eliminating the need for any additional allocations such as arrays or lambda expression captures. The allocation amount is equal to the number of filter classes being used plus 1 (for wrapping the command method), resulting in the shortest execution path.

app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();
app.UseFilter<NopFilter>();

// The above code will generate the following code:

sealed class Command0Invoker(string[] args, Action command) : ConsoleAppFilter(null!)
{
    public ConsoleAppFilter BuildFilter()
    {
        var filter0 = new NopFilter(this);
        var filter1 = new NopFilter(filter0);
        var filter2 = new NopFilter(filter1);
        var filter3 = new NopFilter(filter2);
        var filter4 = new NopFilter(filter3);
        return filter4;
    }

    public override Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
    {
        return RunCommand0Async(context.Arguments, args, command, context, cancellationToken);
    }
}

When an async Task completes synchronously, it returns the equivalent of Task.CompletedTask, so ValueTask is not necessary.

Dependency Injection(Logging, Configuration, etc...)

The execution processing of ConsoleAppFramework fully supports DI. When you want to use a logger, read a configuration, or share processing with an ASP.NET project, using Microsoft.Extensions.DependencyInjection or other DI libraries can make processing convenient.

Lambda expressions passed to Run, class constructors, methods, and filter constructors can inject services obtained from IServiceProvider. Let's look at a minimal example. Setting any System.IServiceProvider to ConsoleApp.ServiceProvider enables DI throughout the system.

// Microsoft.Extensions.DependencyInjection
var services = new ServiceCollection();
services.AddTransient<MyService>();

using var serviceProvider = services.BuildServiceProvider();

// Any DI library can be used as long as it can create an IServiceProvider
ConsoleApp.ServiceProvider = serviceProvider;

// When passing to a lambda expression/method, using [FromServices] indicates that it is passed via DI, not as a parameter
ConsoleApp.Run(args, ([FromServices]MyService service, int x, int y) => Console.WriteLine(x + y));

When passing to a lambda expression or method, the [FromServices] attribute is used to distinguish it from command parameters. When passing a class, Constructor Injection can be used, resulting in a simpler appearance.

Let's try injecting a logger and enabling output to a file. The libraries used are Microsoft.Extensions.Logging and Cysharp/ZLogger (a high-performance logger built on top of MS.E.Logging).

// Package Import: ZLogger
var services = new ServiceCollection();
services.AddLogging(x =>
{
    x.ClearProviders();
    x.SetMinimumLevel(LogLevel.Trace);
    x.AddZLoggerConsole();
    x.AddZLoggerFile("log.txt");
});

using var serviceProvider = services.BuildServiceProvider(); // using for logger flush(important!)
ConsoleApp.ServiceProvider = serviceProvider;

var app = ConsoleApp.Create();
app.Add<MyCommand>();
app.Run(args);

// inject logger to constructor
public class MyCommand(ILogger<MyCommand> logger)
{
    [Command("")]
    public void Echo(string msg)
    {
        logger.ZLogInformation($"Message is {msg}");
    }
}

ConsoleApp has replaceable default logging methods ConsoleApp.Log and ConsoleApp.LogError used for Help display and exception handling. If using ILogger<T>, it's better to replace these as well.

using var serviceProvider = services.BuildServiceProvider(); // using for cleanup(important)
ConsoleApp.ServiceProvider = serviceProvider;

// setup ConsoleApp system logger
var logger = serviceProvider.GetRequiredService<ILogger<Program>>();
ConsoleApp.Log = msg => logger.LogInformation(msg);
ConsoleApp.LogError = msg => logger.LogError(msg);

DI can also be effectively used when reading application configuration from appsettings.json. For example, suppose you have the following JSON file.

{
  "Position": {
    "Title": "Editor",
    "Name": "Joe Smith"
  },
  "MyKey": "My appsettings.json Value",
  "AllowedHosts": "*"
}

Using Microsoft.Extensions.Configuration.Json, reading, binding, and registering with DI can be done as follows.

// Package Import: Microsoft.Extensions.Configuration.Json
var configuration = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddJsonFile("appsettings.json")
    .Build();

// Bind to services( Package Import: Microsoft.Extensions.Options.ConfigurationExtensions )
var services = new ServiceCollection();
services.Configure<PositionOptions>(configuration.GetSection("Position"));

using var serviceProvider = services.BuildServiceProvider();
ConsoleApp.ServiceProvider = serviceProvider;

var app = ConsoleApp.Create();
app.Add<MyCommand>();
app.Run(args);

// inject options
public class MyCommand(IOptions<PositionOptions> options)
{
    [Command("")]
    public void Echo(string msg)
    {
        ConsoleApp.Log($"Binded Option: {options.Value.Title} {options.Value.Name}");
    }
}

public class PositionOptions
{
    public string Title { get; set; } = "";
    public string Name { get; set; } = "";
}

If you have other applications such as ASP.NET in the entire project and want to use common DI and configuration set up using Microsoft.Extensions.Hosting, you can share them by setting the IServiceProvider of IHost after building.

// Package Import: Microsoft.Extensions.Hosting
var builder = Host.CreateApplicationBuilder(); // don't pass args.

using var host = builder.Build(); // use using for host lifetime
using var scope = host.Services.CreateScope(); // create execution scope
ConsoleApp.ServiceProvider = scope.ServiceProvider; // use host scoped ServiceProvider

ConsoleApp.Run(args, ([FromServices] ILogger<Program> logger) => logger.LogInformation("Hello World!"));

ConsoleAppFramework has its own lifetime management (see the CancellationToken(Gracefully Shutdown) and Timeout section), so Host's Start/Stop is not necessary. However, be sure to use the Host itself.

As it is, the DI scope is not set, but by using a global filter, you can add a scope for each command execution. ConsoleAppFilter can also inject services via constructor injection, so let's get the IServiceProvider.

var app = ConsoleApp.Create();
app.UseFilter<ServiceProviderScopeFilter>();

internal class ServiceProviderScopeFilter(IServiceProvider serviceProvider, ConsoleAppFilter next) : ConsoleAppFilter(next)
{
    public override async Task InvokeAsync(ConsoleAppContext context, CancellationToken cancellationToken)
    {
        // create Microsoft.Extensions.DependencyInjection scope
        await using var scope = serviceProvider.CreateAsyncScope();
        await Next.InvokeAsync(context, cancellationToken);
    }
}

However, since the construction of the filters is performed before execution, automatic injection using scopes is only effective for the command body itself.

Publish to executable file

There are multiple ways to run a CLI application in .NET:

run is convenient when you want to execute the csproj directly, such as for starting command tools in CI. build and publish are quite similar, so it's possible to discuss them in general terms, but it's a bit difficult to talk about the precise differences. For more details, it's a good idea to check out build vs publish -- can they be friends? · Issue #26247 · dotnet/sdk.

Also, to run with Native AOT, please refer to the Native AOT deployment overview. In any case, ConsoleAppFramework thoroughly implements a dependency-free and reflection-free approach, so it shouldn't be an obstacle to execution.

v4 -> v5 Migration Guide

v4 was running on top of Microsoft.Extensions.Hosting, so build a Host in the same way and set up a ServiceProvider.

using var host = Host.CreateDefaultBuilder().Build(); // use using for host lifetime
using var scope = host.Services.CreateScope(); // create execution scope
ConsoleApp.ServiceProvider = scope.ServiceProvider;
  • var app = ConsoleApp.Create(args); app.Run(); -> var app = ConsoleApp.Create(); app.Run(args);
  • app.AddCommand/AddSubCommand -> app.Add(string commandName)
  • app.AddRootCommand -> app.Add("")
  • app.AddCommands<T> -> app.Add<T>
  • app.AddSubCommands<T> -> app.Add<T>(string commandPath)
  • app.AddAllCommandType -> NotSupported(use Add<T> manually)
  • [Option(int index)] -> [Argument]
  • [Option(string shortName, string description)] -> Xml Document Comment
  • ConsoleAppFilter.Order -> NotSupported(global -> class -> method declrative order)
  • ConsoleAppOptions.GlobalFilters -> app.UseFilter<T>
  • ConsoleAppBase -> inject ConsoleAppContext, CancellationToken to method

License

This library is under the MIT License.

consoleappframework's People

Contributors

afernandes avatar cympfh avatar dexcompiler avatar epsilongtmyon avatar erjanmx avatar fornever avatar guitarrapc avatar igadmg avatar itn3000 avatar jaxelr avatar kageshiron avatar kristiker avatar masfj avatar mathenn avatar mayuki avatar naratteu avatar neuecc avatar nogic1008 avatar pierre3 avatar taks avatar voxelpluginforproject avatar wipiano avatar xpaw avatar yfakariya avatar zadykian avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

consoleappframework's Issues

Is it possible to have optional parameters?

Have a command which can accept one or two parameters,
Now if I have it declared like
public async Task Cmd([Option(0)] string p1, [Option(1)] string p2)
it complains that I need two parameters. Can I mark second parameter optional somehow?

upd: Found a solution, declaring an overload with one parameter solves the problem
public async Task Cmd([Option(0)] string p1)

Don't know if that is official supported way.

upd:
That approach produce several help lines in the output.

Attaching filter via attribute leads to InvalidOperationException

When ConsoleAppFilter-derived filter is applied via attribute, application crushes with following error:

System.InvalidOperationException: A suitable constructor for type 'ConsoleAppFramework.ConsoleAppFilter' could not be located. Ensure the type is concrete and all parameters of a public constructor are either registered as services or passed as arguments. Also ensure no extraneous arguments are provided.
   at Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance(IServiceProvider provider, Type instanceType, Object[] parameters)
   at Microsoft.Extensions.DependencyInjection.ActivatorUtilities.CreateInstance[T](IServiceProvider provider, Object[] parameters)
   at ConsoleAppFramework.WithFilterInvoker.InvokeAsync() in /Users/zadykian/Repository/personal/ConsoleAppFramework/src/ConsoleAppFramework/ConsoleAppFilter.cs:line 79
   at ConsoleAppFramework.ConsoleAppEngine.RunCore(Type type, MethodInfo methodInfo, Object instance, String[] args, Int32 argsOffset) in /Users/zadykian/Repository/personal/ConsoleAppFramework/src/ConsoleAppFramework/ConsoleAppEngine.cs:line 191
   at ConsoleAppFramework.ConsoleAppEngine.RunCore(Type type, MethodInfo methodInfo, Object instance, String[] args, Int32 argsOffset) in /Users/zadykian/Repository/personal/ConsoleAppFramework/src/ConsoleAppFramework/ConsoleAppEngine.cs:line 207

WebHost Return Value

It would be helpful to be able to return a value instead of the log output, especially in the case of a WebHost.

public class Foo : ConsoleAppBase
{
    public string Echo(string msg)
    {
        return msg;
    }

    public async Task<int> Sum(int x, int y)
    {
        return await Task<int>.Run(()=>
        {
             int result = x + y;
             Console.WriteLine($"The result is {result}");  //Not returned by WebHost 
                                                                                   //OR encapsulated in return object { value: result, output: console output }
             return result;  
        });
    }
}

Feature Request: Allow specifying option description without short name

Background

Currently, help message is generated from [Option] attribute's positional argument. However, for relatively minor options, it is hard to assign short name because they are only 26 chars. So, it is useful to enable specifying help message text without short name.

Proposal

  • Add support for [System.ComponentModel.Description] attribute for command parameters.
  • If both of [Option] with description and [Description] are specified, [Description] to improve sense of unity when options with [Option] and options with [Description] are mixed by specifying all descriptions are specified through [Description] attributes.

Example

public void Foo(
  [Option("i")][Description("Specify input file path.")] string input,
  [Option("o")][Description("Specify output file path.")] string output,
  [Description("Allows overwrite output file.")] bool overwrite = false
)
{
   ...
}

How can I handle white-space in JSON

How can I use white-space in JSON?
The sample parameters in the document, https://github.com/Cysharp/ConsoleAppFramework#complex-argument ,
> SampleApp.exe -array [10,20,30] -person {\"Age\":10,\"Name\":\"foo\"}
works well from Windows CMD, but
> SampleApp.exe -array [10,20,30] -person {\"Age\":10,\"Name\":\"foo bar\"}
will return the error below:
Parameter "person" fail on JSON deserialize, please check type or JSON escape or add double-quotation. args: -array [10,20,30] -person {"Age":10,"Name":"foo bar"}

Please tell me a solution.

String Paramter in the Format "$(MESSAGE)" results in empty String

I have the following app:

ConsoleApp app = ConsoleApp.Create(args);
app.AddCommands<TestCommand>();
app.Run();

public class TestCommand : ConsoleAppBase
{
    public int Execute(string message)
    {
        Console.WriteLine(message);
        return 0;
    }
}

If I execute this application with the following parameter ' execute --message "$(Message)" ', the parameter 'message' is an empty string. It seems passing a argument in the format "$(Something)" will handled in a special way, but I want just the raw string, so in my testcase "$(Parameter)". Is that possible?

Unable to implement IDispose in my ConsoleApp

I want to implement the IDispose interface in my console app class, but the framework complains that there are multiple public methods in my class. Could you exclude the public Dispose method when it tries to find the method to run?

How to show sub command's help?

When I create console app like below, test.exe sub -help shows sub:-help.

    class Program
    {
        static async Task Main(string[] args)
        {
            try
            {
                await Host.CreateDefaultBuilder()
                    .RunConsoleAppFrameworkAsync<Commander>(args);
            }
            catch (Exception ex)
            {
                Console.WriteLine(ex);
                throw;
            }
        }
    }

    class Commander : ConsoleAppBase
    {
        public void MainCommand([Option(0, "message to show.")] string message)
        {
            Console.WriteLine(message);
        }

        [Command("sub")]
        public void SubCommand([Option(0, "original message.")] string orgMessage)
        {
            Console.WriteLine($"sub:{orgMessage}");
        }
    }

I want to show something like

Usage: test sub <original message.>

Arguments:
  [0] <String>    original message.

What can I do to achieve this ?

How to override ShowVersion (and ShowHelp)?

Unless I am missing something, I think it's currently not possible to override these to provide my own implementation (I want to print extra stuff in version for example).

Some way to specify my own methods for these that return strings would be great.

If no arguments are passed, no `help` is performed.

I wrote the following code.

    class Program: ConsoleAppBase
    {
        private static async Task Main(string[] args)
        {
            await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
        }

        public void Run()
        {

        }

        [Command("login")]
        public async Task Login(string ipAddress, string username, string password)
        {
            // login command
        }
    }

I ran as follows, but no help is displayed without arguments.

>sampleapp.exe

>sampleapp.exe help
Usage: sampleapp

Usage: sampleapp <Command>

Commands:
  login

I want help without any arguments.
Please tell me if you have any mistakes.

パラメータバインディングに失敗する

便利に使わさせていただいています。
Ver.2.4.0に更新したところ、標記の問題が出たので相談させて下さい。

ConsoleApp1.exe
Usage: ConsoleApp1 <str> [options...]

Arguments:
  [0] <String>    str

Options:
  -b, -p_bool <Boolean>    bool (Default: False)

となっている際に、実行結果が下記のようになってしまいます。
(-bがバインドされない)

ConsoleApp1.exe "input" -b true
str:input, bool:True

ConsoleApp1.exe "input" -b false
str:input, bool:True

ConsoleApp1.exe "input" -b True
str:input, bool:True

ConsoleApp1.exe "input" -b False
str:input, bool:True

Ver.2.0.2では起きないようです。
下記が再現ソースです。
ConsoleApp1.zip

お手数ですが、確認をお願いします。

Feature Request: Support Command Aliases

(Previous issue title: Aliases are displayed twice in command list)

On the command help, command aliases are displayed twice.

Sample

Code

var app =
    ConsoleApp.Create(args)
    .AddCommands<Program>();

app.Run();

partial class Program : ConsoleAppBase
{
    [Command(new[] { "s", "sa", "sample" }, "Sample command.")]
    public void Sample() {
        Console.WriteLine("Hello from console app!");
    }
}

Output of dotnet run help

Commands:
  help                        Display help.
  s, sa, sample sa, sample    Sample command.
  version                     Display version.

Aliases sa and sample are displayed twice.

Cause

commandHelpDefinitions.Select(... in method BuildMethodListMessage (here) seems to generate the wrong help text.

x.Command already contains the aliases, but it gets concatinated with x.CommandAliases.

Environment

  • ConsoleAppFramework 4.2.4
  • .NET SDK 7.0.101
  • Windows 10 Pro 22H2 x64

Validation causes build error CS0103 if using a class-based approach

ver.5.1.0 にて、クラスベースの方法でコマンドを追加する際に、System.ComponentModel.DataAnnotations.RangeAttribute を使用していると、ビルドに失敗します。
[Range(0, 1)] の部分を削除すれば、ビルドに成功します。

using System.ComponentModel.DataAnnotations;
using ConsoleAppFramework;

var app = ConsoleApp.Create();
app.Add<Test>();
app.Run(args);

public class Test
{
    public void Show([Range(0, 1)] double value) => ConsoleApp.Log($"{value}");
}

ビルド結果:

Error (active)	CS0103	The name 'command' does not exist in the current context	ConsoleApp1	F:\src\ConsoleApp1\obj\Debug\net8.0\ConsoleAppFramework\ConsoleAppFramework.ConsoleAppGenerator\ConsoleApp.Builder.g.cs	98

[Help wanted] Prevent the console app to got terminated after the Run method call

So, I'm testing the very friendly and initial examples like that:

var app = ConsoleApp.Create(args);
app.AddCommands<Foo>();
app.Run();

But when I build/run the app, my console just got terminated and I didn't have a chance to make any input. I tried to use Console.ReadLine() right after the app.Run() but it is not working and I didn't get the command response either.

The only way I was able to see it working was by calling this in the terminal at solution directory e.g. dotnet run -- sum 2 2

I would like to have something I could debug, is there a proper way or any tips to achieve that, please? I mean, something to keep console app open and just terminated with some command like e.g. --exit.

Supply multiple values using repeated arguments

Allow multiple command line arguments with the same name to bind to an array parameter. This would avoid separating on comma.

public void Command(string[] name)

my-cli command —name Foo —name Bar
name = [“Foo”, “Bar”]

my-cli command —name Foo —name Bar,Baz
name = [“Foo”, “Bar,Baz”]

Minimal API

Try to make single-line console application with argument parsing.

Similar as ASP .NET Minimal API.

ConsoleApp.Create(args).Run((string foo, int bar) =>
{
    // do...
}) ;

or(and...)

var app = ConsoleApp.Create(args);
app.AddCommand("foo", (string foo, int bar) =>
{
}) ;

app.AddCommand("bar", ([Option("f")]string foo, [Option("bar")]int bar) =>
{
}) ;

app.Run();

More than 16 arguments when using a class method

This works:

ConsoleApp.Run(args, (
    bool a1,
    bool a2,
    bool a3,
    bool a4,
    bool a5,
    bool a6,
    bool a7,
    bool a8,
    bool a9,
    bool a10,
    bool a11,
    bool a12,
    bool a13,
    bool a14,
    bool a15,
    bool a16,
    bool a17,
    bool a18,
    bool a19,
    string a20 = "test"
    ) => { });

But when doing the following, it fails to generate because Func<> does not accept more than 16 arguments (it doesn't generate a delegate like the anonymous lambda does).

Argument 2: cannot convert from 'method group' to 'System.Func'

public partial class Test
{
	public static void Main(string[] args)
	{
		var test = new Test();
		ConsoleApp.Run(args, test.Handle);
	}
	
	public void Handle(
		bool a1,
		bool a2,
		bool a3,
		bool a4,
		bool a5,
		bool a6,
		bool a7,
		bool a8,
		bool a9,
		bool a10,
		bool a11,
		bool a12,
		bool a13,
		bool a14,
		bool a15,
		bool a16,
		bool a17,
		bool a18,
		bool a19,
		string a20 = "test"
	)
	{
		//
	}
}

Side question: would it be possible to make it support class constructors, so that I could use readonly fields for options? For example: ConsoleApp.Run<Test>(args);

CommandAttribute did not work?

Environment

  • dotnet core sdk 3.0pre6
  • MicroBatchFramework: 1.2.0

Steps to reproduce

  1. create console project(and set TargetFramework to netcoreapp2.1)
  2. write following source to Program.cs
  3. do dotnet run -- test -arg1 aaaa
using System;
using System.Threading.Tasks;
using MicroBatchFramework;

namespace microbatchframeworktest
{
    class MyBatch : BatchBase
    {
        [Command("test")]
        public void MyTest(string arg1)
        {
            Console.WriteLine("hello {0}", arg1);
        }
    }
    class Program
    {
        static async Task Main(string[] args)
        {
            await BatchHost.CreateDefaultBuilder().RunBatchEngineAsync(args);
        }
    }
}

Expected behavior

print "hello aaaa"

Actual behavior

print "Type or method does not found on this Program. args: test -arg1 aaaa" then exit.

Additional

  • dotnet run -- MyBatch.MyTest -arg1 aaaa seems to work
  • did dotnet run -- list, then output MyBatch.MyTest was output

Fail to match method parameter on XXXX in Swagger

 class Program : ConsoleAppBase // inherit ConsoleAppBase
    {
        static async Task Main(string[] args)
        {
            // target T as ConsoleAppBase.
            await Host.CreateDefaultBuilder(args).RunConsoleAppFrameworkWebHostingAsync("http://localhost:12345");
        }

        // allows void/Task return type, parameter is automatically binded from string[] args.
        public void Run2([Option("n", "name of send user.")] string name, int repeat = 3)
        {
            for (int i = 0; i < repeat; i++)
            {
                Console.WriteLine($"Hello My 2 ConsoleApp from {name}");
            }
        }

      //  [Command("timer")]
        public async Task Timer([Option(0)] uint waitSeconds)
        {
            Console.WriteLine(waitSeconds + " seconds");
            while (waitSeconds != 0)
            {
                // ConsoleAppFramework does not stop immediately on terminate command(Ctrl+C)
                // so you have to pass Context.CancellationToken to async method.
                await Task.Delay(TimeSpan.FromSeconds(1), Context.CancellationToken);
                waitSeconds--;
                Console.WriteLine(waitSeconds + " seconds");
            }
        }
    }

when invoke timer throuth swagger will show next error

fail: ConsoleAppFramework.ConsoleAppEngine[0]
      Fail to match method parameter on Program.Timer. args: Program.Timer -waitSeconds 1
      System.InvalidOperationException: Required argument 0 was not found in specified arguments.
         at ConsoleAppFramework.ConsoleAppEngine.TryGetInvokeArguments(ParameterInfo[] parameters, String[] args, Int32 argsOffset, Object[]& invokeArgs, String& errorMessage)
         at ConsoleAppFramework.ConsoleAppEngine.RunCore(Type type, MethodInfo methodInfo, String[] args, Int32 argsOffset)

remove [Option(0)] in method Timer , can solve this problem

q2:how set response body when use WebHost

to 1.0.0 release

  • More Unit Test
  • Swagger Integration
  • Command Alias
  • Override Help and List
  • Pack to Docker Sample
  • Scheduler Sample
  • Modify CreateDefaultBuilder

DI doesn't work on "Development" environment

environment

.NET Core 3.0.100
MicroBatchFramework 1.5.0

repro code

class Program
{
    static async Task Main(string[] args)
    {
        await BatchHost.CreateDefaultBuilder()
            .UseEnvironment("Development") // works fine if this line is commented out
            .ConfigureServices((hostContext, services) =>
            {
                services.AddScoped<IHoge, Hoge>();
            })
            .RunBatchEngineAsync<MyFirstBatch>(args);
    }
}

public interface IHoge
{
    void Test();
}

public class Hoge : IHoge
{
    public void Test() => Console.WriteLine("Hoge");
}

public class MyFirstBatch : BatchBase
{
    private readonly IHoge hoge;

    public MyFirstBatch(IHoge hoge)
    {
        this.hoge = hoge;
    }

    public void Hello()
    {
        hoge.Test();
        Console.WriteLine("MyFirstBatch");
    }
}

expected

Hoge
MyFirstBatch

actual

Fail to create BatchBase instance. Type:ConsoleApp4.MyFirstBatch
System.InvalidOperationException: Cannot resolve 'ConsoleApp4.MyFirstBatch' from root provider because it requires scoped service 'ConsoleApp4.IHoge'.
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.CallSiteValidator.ValidateResolution(Type serviceType, IServiceScope scope, IServiceScope rootScope)
   at Microsoft.Extensions.DependencyInjection.ServiceProvider.Microsoft.Extensions.DependencyInjection.ServiceLookup.IServiceProviderEngineCallback.OnResolve(Type serviceType, IServiceScope scope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngine.GetService(Type serviceType, ServiceProviderEngineScope serviceProviderEngineScope)
   at Microsoft.Extensions.DependencyInjection.ServiceLookup.ServiceProviderEngineScope.GetService(Type serviceType)
   at MicroBatchFramework.BatchEngine.RunCore(BatchContext ctx, Type type, MethodInfo methodInfo, String[] args, Int32 argsOffset)

[RootCommand] for each ConsoleAppBase class

I have 3 classes (A, B, C) to structure my commands. Now i want to have a RootCommand for each of those classes.
I added [RootCommand] over a Function (Func) for each class.
The expected result would be that i can use it like this:

MyTool A
MyTool B
MyTool C

However this does not seem to work, as i have to use it like this:

MyTool A Func
MyTool B Func
MyTool C Func

Is there something wrong with my setup or am i misunderstanding how [RootCommand] should work?

Sort/group subcommands in help output.

Currently if commands are added via AddCommand and AddSubcommand they are mixed in help output (looks like they are sorted by Command name alphabetically), so subcommands can be spread through the help list. Like this

add class
create
generate
add workspace
version

Would be great if they are grouped by upper command.

Proposal: Boolean option legend should not show its value type

Currently, bool typed options are shown in help message like -o, --option <Boolean> (description...). It looks that specifying boolean value is required despite it is omittable (we can specify the option with -o only in above example). Many users cannot notice this fact. So, it is better to suppress <Boolean>, such as -o, --option (description...) for previous example, when all following conditions are met:

  • The option is optional (has default value).
  • The default value is false.

Ctrl+C not captured when waiting on Console.ReadKey

I'm building an interactive console app where arguments can be specified on the command line, or the user will be asked to enter the argument values at run-time.

According to the docs, ConsoleAppFramework handles the cancel event? I've set the shutdown time to zero so I'd expect the app to terminate immediately on Ctrl+C even tho we're waiting on the user.

I've tried handling Ctrl+C myself by setting Console.TreatControlCAsInput = true and checking the returned key info. This seems to work in debug but not when running in release mode.

Any help would be appreciated.

Global filters with dependency injection

The documentation specifies⬇️

// Filter is instantiated by DI so you can get parameter by constructor injection.

But only shows how to add global filters where the instance is instantiated upfront

var app = ConsoleApp.Create(args, options =>
{
    options.GlobalFilters = new ConsoleAppFilter[]
    {
        new MutextFilter() { Order = -9999 } ,
        new LogRunningTimeFilter() { Oder = -9998 }, 
    }
});

Is there a way to use Dependency Injection with global filters, that I've just missed from the documentation?

e.g.

// filter class
public class MyFilter : ConsoleAppFilter
{
  private readonly ILogger<MyFilter> logger;
  public MyFilter(ILogger<MyFilter> logger)
  {
    this.logger = logger;
  }

  public override async ValueTask Invoke(ConsoleAppContext context, Func<ConsoleAppContext, ValueTask> next)
  {
    logger.LogInformation(context.MethodInfo.Name);
    await next(context);
  }
}

// and adding the filter
var builder = ConsoleApp.CreateBuilder(args, options =>
  {
    options.AddFilter<MyFilter>;
  });

Do not print the stack trace for `ArgumentException`

Since it's being thrown by the generated code and is expected to be visible by the user, I don't think the stacktrace provides any value.

Current:

System.ArgumentException: Argument '-abc' does not found in command prameters.
   at ConsoleAppFramework.ConsoleApp.ThrowArgumentNameNotFound(String argumentName) in ConsoleApp.cs:line 110
   at ConsoleAppFramework.ConsoleApp.Run(String[] args, Func`15 command) in ConsoleApp.Run.g.cs:line 242

Suggestion:

Argument '-abc' does not found in command prameters.

The source generation is non-incremental

The source generation is completely non incremental due to rooting of compilation objects. There should not be *Syntax or symbol instances in the compilation pipeline, as they completely break the caching and increase memory usage. It's important to establish a value type data model of the things required for generating the source, see more here.

}, (context, ct) => ((InvocationExpressionSyntax)context.Node, context.SemanticModel));

and
}, (context, ct) => (
(InvocationExpressionSyntax)context.Node,
((context.Node as InvocationExpressionSyntax)!.Expression as MemberAccessExpressionSyntax)!.Name.Identifier.Text,
context.SemanticModel))

Many libraries fall into this pit of failure. See similar issues in many other libraries that used IIncrementalGenerator in correctly:

Related Roslyn issues:

Here's solid article from Andrew Lock about the issues: https://andrewlock.net/creating-a-source-generator-part-9-avoiding-performance-pitfalls-in-incremental-generators/

You can see here how one library fixed their incremental generator: k94ll13nn3/AutoConstructor#83

It is also possible to add tests to test whether a pipeline is incremental:

Allow type arguments

This would allow using readonly fields in the class. Currently to do this I think I would need to have a method to accept the arguments, and then duplicate them in the class.

I'm thinking something like this:

        public static void Main(string[] args)
        {
            var appInstance = ConsoleApp<App>.Run(args);
        }

        public class App
        {
            /// <summary>
            /// Test app.
            /// </summary>
            /// <param name="foo">A</param>
            /// <param name="bar">B</param>
            public App(int foo, int bar)
            {
                Console.WriteLine(foo + bar);
            }
        }

Is this a reasonable idea?

Failed to load assembly 'Microsoft.Bcl.AsyncInterfaces' on .NET Core 3.1

I faced to crash ConsoleAppFramework when it stops its execution.

Workaround

I can avoid this issue with adding explicit dependency to Microsoft.Bcl.AsyncInterface to my app.

Details

Unhandled exception. System.AggregateException: One or more hosted services failed to stop. (Could not load file or assembly 'Microsoft.Bcl.AsyncInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'.  )
 ---> System.IO.FileNotFoundException: Could not load file or assembly 'Microsoft.Bcl.AsyncInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'.
File name: 'Microsoft.Bcl.AsyncInterfaces, Version=1.0.0.0, Culture=neutral, PublicKeyToken=cc7b13ffcd2ddd51'
   at ConsoleAppFramework.ConsoleAppEngineService.StopAsync(CancellationToken cancellationToken)
   at System.Runtime.CompilerServices.AsyncMethodBuilderCore.Start[TStateMachine](TStateMachine& stateMachine)
   at ConsoleAppFramework.ConsoleAppEngineService.StopAsync(CancellationToken cancellationToken)
   at Microsoft.Extensions.Hosting.Internal.Host.StopAsync(CancellationToken cancellationToken)


   --- End of inner exception stack trace ---
   at Microsoft.Extensions.Hosting.Internal.Host.StopAsync(CancellationToken cancellationToken)
   at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.WaitForShutdownAsync(IHost host, CancellationToken token)
   at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
   at Microsoft.Extensions.Hosting.HostingAbstractionsHostExtensions.RunAsync(IHost host, CancellationToken token)
   at MYAPP.Program.Main(String[] args)
   at MYAPP.Program.<Main>(String[] args)

As long as I investigated this problem, it caused because ConsoleAppFramework indirectly depends on Microsoft.Bcl.AsyncInterfaces via Microsoft.Extensions.Hosting in netstandard2.0 and does not provide netcoreapp3.1.
When MYAPP uses netcoreapp3.1, it ultimately depends on netstandard2.0 ConsoleAppFramework which indirectly depends on Microsoft.Bcl.AsyncInterfaces, and indirectly depends on netcoreapp3.1 Microsoft.Extensions.Hosting which does not depend on Microsoft.Bcl.AsyncInterfaces. So, Microsoft.Bcl.AsyncInterfaces is not included in published app binaries because selected Microsoft.Extensions.Hosting version does not require it.

Proposal

I think possible solution is one of following:

  • Add netcoreapp3.1 version to ConsoleAppFramework.
  • Add explicit dependency for Microsoft.Bcl.AsyncInterface to netstandard2.0 version of ConsoleAppFramework.
  • Write about workaround in Readme.md.

warning CS8625: Cannot convert null literal to non-nullable reference type

When project does not have nullables enabled, but assigns default or null as default type for strings, CS8625 is triggered because the generated code has #nullable, but the parameter is not string?

        ConsoleApp.Run(args, Test);

        private static void Test(
            bool a1,
            bool a2,
            bool a3,
            bool a4,
            bool a5,
            bool a6,
            bool a7,
            bool a8,
            bool a9,
            bool a10,
            bool a11,
            bool a12,
            bool a13,
            bool a14,
            bool a15,
            bool a16,
            bool a17,
            bool a18,
            bool a19,
            string foo = null)
        {
            Console.WriteLine(foo);
        }

RunConsoleAppFrameworkAsync method only searches ConsoleApp from loaded assemblies

The document of RunConsoleAppFrameworkAsync method says "Run multiple ConsoleApp that are searched from all assemblies". However, when I looked at GetConsoleAppTypes method, it actually only search in assemblies returned by AppDomain.CurrentDomain.GetAssemblies(). The GetAssemblies method returns only loaded assemblies.

Currently, to workaround this, I have to reference a type of target assemblies or force them to be loaded before calling RunConsoleAppFrameworkAsync method.

Change usage name

Is there any way to change the usage name, besides renaming your project?
image
To be more specific I'd like to replace Retrospect.CLI in the image above with something else.

Any way how to implement sub commands?

Need to implement nested commands, something like

program.exe command subcommand1
program.exe command subcommand2
etc.

Any way how to implement it?

One way that can be possibly implemented by having [Command("command subcommand1")] but that does not work now.

Add a way to specify the default value

Currently the default value is taken directly from the method syntax [void Run(int foo = 0)] would yield the default value of 0 in the help message. However, some types can be easily defined as a default, such as DateTime?. In these cases, the null value is checked, and if there, a value is assigned.

So, a method syntax of: void Run(DateTime? date = null) currently shows null, but the actual default is set to DateTime.Today if the value is null.

Can the Options attribute be modified to take an alternative text, or maybe a Default attribute used to override the default behavior?

Console app that contains a single command doesn't show the detail for help.

When help [command] is executed for an app that contains a single command with CommandAttribute, the app shows the command list rather than the detail of that command.

Steps to reproduce

  1. Create .NET Core Console App named ConsoleApp1 (TargetFramework = netcoreapp2.1; LangVersion = latest)
  2. Install ConsoleAppFramework 2.0.2
  3. dotnet build and dotnet run -p ConsoleApp1 -- help escape
using System;
using System.Threading.Tasks;
using ConsoleAppFramework;
using Microsoft.Extensions.Hosting;
namespace ConsoleApp1
{
    class Program : ConsoleAppBase
    {
        static async Task Main(string[] args)
        {
            await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
        }
        [Command("escape")]
        public void UrlEscape([Option("i", "URL")] string input = "")
        {
            Console.WriteLine(Uri.EscapeDataString(input));
        }
    }
}

Expected

Usage: ConsoleApp1 escape [options...]

Options:
  -i, -input <String>    URL (Default: )

Actual

Usage: ConsoleApp1 <Command>

Commands:
  escape     

Workarounds

  • Don't use CommandAttribute for a single command.
  • Add any command.

Environment

  • Windows 10 v1809
  • Visual Studio 2017.9
  • .NET Core SDK 2.1.511
  • .NET Core runtime 2.1.15

Indexed Options do not work properly

インデックス付きオプションがインデックス無しのオプションと組み合わせて指定されると期待した動作をしません。他の名前付きオプションを取り除いた状態から0,1,2...とインデックスオプションを解決するのが自然かと思います。

sampe

class Program : ConsoleAppBase
{
    static async Task Main()
    {
        Console.WriteLine("(1) -number 5 foo");
        var args = new string[] { "-number", "5", "foo" };
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);

        Console.WriteLine("(2) bar -number 5 foo");
        args = new string[] { "bar", "-number", "5", "foo" };
        await Host.CreateDefaultBuilder().RunConsoleAppFrameworkAsync<Program>(args);
    }

    public void Hello(int number, [Option(0)] string file = null)
    {
        Console.WriteLine(file);
        Console.WriteLine(number);
    }
}

expected

(1) -number 5 foo
foo
5
(2) bar -number 5 foo
bar
5

actual

(1) -number 5 foo
5
5
(2) bar -number 5 foo
-number
5

move/hide the "code generated" part in Readme

Total nit pick!

I had a quick look at this project,
When doing a super quick scroll over the readme it looks like the code you have to write is the source generated code.

Instant rejection.

I would remove/move the source generated code, as first impressions matter.
Especially for lazy people like myself!

Serializing Dictionary<string,string>

Is there any way to serialize arguments into Dictionary<string,string>?

> MyCmd.exe  -q {{"Key1": "Value1*"}}
Error: fail on JSON deserialize, please check type or JSON escape or add double-quotation.

Proposal: New API to return Exit Code

I'm replacing a TRADITIONAL batch program with this Framework.
However, sadly, these OLD programs are linked to other systems by their exit codes.
So I need new API to return an exit code in this framework.

Something like this.

class Program
{
    static Task<int> Main(string[] args)
        => await BatchHost.CreateDefaultBuilder().RunBatchEngineWithExitCodeAsync<SampleBatch>(args);
}

class SampleBatch : BatchBase
{
    [Command("sync")]
    public int ParseInt(string source) => int.TryParse(source, out var i) ? i : -1;

    [Command("async")]
    public async Task<int> ParseIntAsync(string source)
    {
        Context.Logger.LogInformation("Wait...");
        await Task.Delay(1000);
        return ParseInt(source);
    }
}

Proposal: always handle values with dashes as parameter names

Proposal

Change behaviour of ConsoleAppEngine.TryGetInvokeArguments method to always handle values starting with dashes as parameter names but not values.

In this case it would be required to pass value starting with dashes wrapped into quotes. For example:

MyApp.Exe command --some-arg "--arg-value-with-leading-dashes"

Motivation

Current parsing mechanism doesn’t provide correct error messages in case if one of multiple parameters is passed without value. For example:

App definition:

public class CommandTests_Single_Two_Required : ConsoleAppBase
{
    [RootCommand]
    public void Hello(string firstName, string lastName) => Console.WriteLine($"Hello {firstName} {lastName}");
}

Input:

MyApp.Exe --first-name --last-name "TestLastName"

Output:

Required parameter "last-name" not found in argument. args: --first-name --last-name TestLastName

So, "--last-name" is parsed as value of --first-name parameter

Expecting output:

Value for parameter "first-name" is not provided.

Global exception handler not catching

currently if there is an exception trown in the app I get the full ToString() of an exception
I would like to add some global exception handler witch is looking like:
System.AppDomain.CurrentDomain.UnhandledException += UnhandledExceptionHandler;
static void UnhandledExceptionHandler(object sender, UnhandledExceptionEventArgs e) { // do something here Environment.Exit(1); }

but this never catches

I did not find any info on how to handle exceptions in the docs

Passing a quoted string with a short parameter breaks Microsoft CommandLine engine

Here is my case. I am writing an app which need to accept parameter switches for another app it is using. So for example I am trying to call my app

./myapp command -msg=hello random parameters here

or even like this

./myapp command "-msg=hello random parameters here"

And that break everything with exception System.FormatException: 'The short switch '-msg=hello random parameters here' is not defined in the switch mappings.'

Looks like HostBuilder takes that parameters personal and try to parse them for some reason. Looks like not an issue of ConsoleAppFramework but still maybe there is a way to fix that?

One solution I found is to wrap that as a named parameter. For example

./myapp command --parameters "-msg=hello random parameters here"

But that look ugly. Will require escaping of internal quotes etc.

So the question is is it possible to prevent HostBuilder from parsing parameters, and why is it doing it? I just want to get that strings in my app and don't really care if my data is valid for HostBuilder or not. Or that is a limitation of .net platform and we are doomed to live with that?

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.