farlee2121.System.CommandLine.PropertyMapBinder 1.0.0

dotnet add package farlee2121.System.CommandLine.PropertyMapBinder --version 1.0.0
NuGet\Install-Package farlee2121.System.CommandLine.PropertyMapBinder -Version 1.0.0
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="farlee2121.System.CommandLine.PropertyMapBinder" Version="1.0.0" />
For projects that support PackageReference, copy this XML node into the project file to reference the package.
paket add farlee2121.System.CommandLine.PropertyMapBinder --version 1.0.0
#r "nuget: farlee2121.System.CommandLine.PropertyMapBinder, 1.0.0"
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
// Install farlee2121.System.CommandLine.PropertyMapBinder as a Cake Addin
#addin nuget:?package=farlee2121.System.CommandLine.PropertyMapBinder&version=1.0.0

// Install farlee2121.System.CommandLine.PropertyMapBinder as a Cake Tool
#tool nuget:?package=farlee2121.System.CommandLine.PropertyMapBinder&version=1.0.0

System.CommandLine.PropertyMapBinding

alternate text is missing from this package README image

Motivation / what is this

The goal is to create an intuitive handler binding experience for System.CommandLine. A few sub-goals include

  • intuitive binding of complex types
  • blending multiple binding rules for a customizable and consistent binding experience
  • easy extension of the binding pipeline
  • support handler declaraction as a self-contained expression (no reference to symbol instances)

Examples

All examples assume the following definitions are available

Option<int> frequencyOpt = new Option<int>(new string[] { "--frequency", "-f" }, "such description");

RootCommand rootCommand = new RootCommand("Test Test")
{
    new Argument<string>("print-me", "gets printed"),
    frequencyOpt, 
    new Option<IEnumerable<int>>(new string[] { "--list", "-l" }, "make sure lists work")
    {
        Arity = ArgumentArity.ZeroOrMore
    };
};

public static async Task SuchHandler(SuchInput input)
{
    Console.WriteLine($"printme: {input.PrintMe}; \nfrequency: {input.Frequency}; \nlist:{string.Join(",",input.SuchList)}");
}

public class SuchInput {
    public int Frequency { get; set; }
    public string? PrintMe { get; set; }
    public IEnumerable<int> SuchList { get; set; } = Enumerable.Empty<int>();
}

Pipeline

The backbone construct is BinderPipeline.

rootCommand.Handler = new BinderPipeline<SuchInput>()
    .MapFromName("print-me", model => model.PrintMe)
    .MapFromReference(frequencyOpt, model => model.Frequency)
    .MapFromName("-l", model => model.SuchList)
    .ToHandler(SuchHandler);

BinderPipeline is really a collection of IPropertyBinder. Each IPropertyBinder defines a strategy for assigning input to the target object. The pipeline executes each binder in the order they are given. This means later binders will override earlier ones. This also means we can

  • use multiple rules to bind properties
  • define a priority/fallback chain for any given property

Blended Conventions

The pipeline can handle many approaches to binding input. Here's an example using a simple naming convention with an explicit mapping fallback

rootCommand.Handler = new BinderPipeline<SuchInput>()
    .MapFromNameConvention(NameConvention.PascalCaseComparer)
    .MapFromName("-l", model => model.SuchList)
    .ToHandler(SuchHandler);

More conventions can be added to this pipeline. Here are some cases I haven't implemented, but would be fairly easy to add

  • map default values from configuration
  • Ask a user for any missing inputs
    • can be done with the existing setter overload, but prompts could be automated with a signature like .PromptIfMissing(name, selector)
  • match properties based on type

See How to Extend for more detail.

Binding To Existing Models

Sometimes we might want to initialize our input model separately from the input binding process (e.g. default model from configuration).

That's easy enough

SuchInput existingModelInstance = //...
rootCommand.Handler = new BinderPipeline<SuchInput>()
    .ToHandler(SuchHandler, existingModelInstance);

Initializing a model with required data

Some models may want to enforce guarantees about data through the constructor, or some fields may not allow modification after initialization.

This can be handled similarly to System.Commandline's SetHandler.

IModelFactory<SuchInput> modelFactory = ModelFactory.FromSymbolMap((int frequency, string printMe) => new InputModel(frequency, printMe), frequencyOpt, printMeArg);
rootCommand.Handler = new BinderPipeline<SuchInput>()
    .ToHandler(SuchHandler, modelFactory);

The same can be accomplished with option and argument aliases

IModelFactory<SuchInput> modelFactory = ModelFactor.FromNameMap((int frequency, string printMe) => new InputModel(frequency, printMe), "-f", "print-me");
rootCommand.Handler = new BinderPipeline<SuchInput>()
    .ToHandler(SuchHandler, modelFactory);

How to extend

Extending the pipeline is fairly easy.

The core contract is

public interface IPropertyBinder<InputModel>
{
    InputModel Bind(InputModel InputModel, InvocationContext context);
}

IPropertyBinder takes an instance of the target input class and the invocation context provided by the parser.

Input definitions (i.e. options and arguments) can be found in context.ParserResult.CommandResult.Symbol.Children and values can be fetched by functions like context.ParseResult.GetValueForOption.

Examples exist for symbol name and property path and simple name conventions.

The other key step is to register extension methods on BinderPipeline. The main behaviors to consider

  • the extension should add it's binder to the end of the pipeline (e.g. pipeline.Add(yourBinder))
  • The extension should return the modified copy of the pipeline (i.e. always has return type BinderPipeline<T>)
// Example pipeline extension
public static class BinderPipelineExtensions{
    public static BinderPipeline<InputModel> MapFromNameConvention<InputModel>(this BinderPipeline<InputModel> pipeline, NameConventionComparer comparer)
    {
        pipeline.Add(new NameConventionBinder<InputModel>(comparer)); // this adds an IPropertyBinder<T>
        return pipeline; // be sure to return the pipeline for further chaining
    }
}

How to handle Dependency Injection?

Short: Invoke the dependency container in the handler function (i.e ToHandler(handlerFunction))

This position of the library is to keep dependency injection separate from model binding. Some reasons include

  • Keeping the two activities separate simplifies error diagnosis and improves code clarity
  • Dependency containers can easily be invoked from within the handlers
  • Input values may need registered with the dependency container, which requires the input model to be complete before the dependency container
  • The input model should only model the possible input. It is not responsible for composition or behavior.
    • The handler function exists to bridge between the input model and consumers.

Status of project

A successful experiment. The core builder experience is likely stable, but the API could still change given feedback/experience.

The library is usable, has tests, but has no guarantees (including support).

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 was computed.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on farlee2121.System.CommandLine.PropertyMapBinder:

Package Downloads
farlee2121.System.CommandLine.PropertyMapBinder.NameConventionBinder

A System.CommandLine.PropertyMapBinder extension for mapping console input to properties by simple naming conventions

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last updated
1.0.0 1,519 3/23/2022
1.0.0-preview1 1,048 3/5/2022
0.1.0-beta1 948 1/13/2022
0.1.0-alpha1 136 1/9/2022