Skip to content

Latest commit

 

History

History
652 lines (528 loc) · 24.5 KB

README.md

File metadata and controls

652 lines (528 loc) · 24.5 KB

clipr: A Command Line Interface ParseR for .Net 3.5+ and .Net Core

Created by Dan Nemec

NuGet NuGet NuGet

This command line parser library is very much inspired by the argparse library for Python. It's one of the easiest parser libraries I've used while still offering a comprehensive set of powerful features.

I've borrowed and ported many of its features to C# taking advantage of C#'s static typing features where it makes sense. Thanks to the powerful nature of the Reflection and TypeDescriptor assemblies, it's quite easy to take the arguments already sent to any Console project and fill a class with those values, even if they contain custom Types. Here's a quick but powerful example of the objects this library enables you to build:

[ApplicationInfo(Description = "This is a set of options.")]
public class Options
{
    [NamedArgument('v', "verbose", Action = ParseAction.Count,
        Description = "Increase the verbosity of the output.")]
    public int Verbosity { get; set; }

    [PositionalArgument(0, MetaVar = "OUT",
        Description = "Output file.")]
    public string OutputFile { get; set; }

    [PositionalArgument(1, MetaVar = "N",
        NumArgs = 1,
        Constraint = NumArgsConstraint.AtLeast,
        Description = "Numbers to sum.")]
    public List<int> Numbers { get; set; } 
}

static void Main()
{
    var opt = CliParser.Parse<Options>(
        "-vvv output.txt 1 2 -1 7".Split());
    Console.WriteLine(opt.Verbosity);
    // >>> 3

    Console.WriteLine(opt.OutputFile);
    // >>> output.txt

    var sum = 0;
    foreach (var number in opt.Numbers)
    {
        sum += number;
    }
    Console.WriteLine(sum);
    // >>> 9
}

Changelog

Master

2018-03-25

  • Fix minor help page formatting errors.
  • Add Verbs/Commands to help page.
  • Add the ability to configure the TextWriter for help and version output so it is no longer writing only to Console.Error.
  • Allow each verb to inherit the --help trigger so that you can get detailed help information for verbs.

2017-07-13 1.6.0.1

  • Fix a bug in parsing consecutive Optional values.
  • Fix minor resx issues.

2017-01-02 1.6.0

  • Add ability to mask password.
  • Add "optional" value constraint for reference or nullable types.
  • Add section in help generation for required named arguments.
  • Fixed bug in .Net Core localization resource name.

Full Changelog

CliParser vs. CliParser<>

If you don't need any special options or custom help generators, the static CliParser class is the easiest way to initialize the parser. Behind the scenes it will create the parser and begin parsing the arguments passed in. If the destination type has a parameterless construtor, you don't even need to set up the object first! It can new() up an object for you and spit it out after parsing is complete, ready to be used.

Parse vs. Tryparse vs. StrictParse

There are three ways to parse a list of arguments. The former, Parse(), will attempt to parse the input arguments and throw a ParseException if something went wrong while parsing or a ParserExit exception if the help or version information were triggered and printed to the console.

The TryParse() method is similar to the typical TryParse methods found on integers and datetimes. It returns a boolean value of true if parsing succeeded, false otherwise. There is one overload that lets you input an instance of the type you want to parse (in cases where the constructor takes parameters), but the other overload uses the out keyword to construct a new instance of the type before parsing. If parsing fails, that instance will be null.

The StrictParse() method was made for a very specific use case -- most applications that parse arguments, when they encounter an invalid argument or some other error, will print help / usage information and immediately quit, letting the user correct her mistakes and rerun the program. In that spirit, the StrictParse() method will not throw any exceptions (if you see one, report it on the Github page). Instead, it will print the error message and the one-line usage documentation, then terminate using Environment.Exit. Note that your program will not have the opportunity to clean up when that happens. If you've allocated any unmanaged resouces or left some important files in a half-written state, unpredictible things may happen. Of course, parsing arguments is usually the first thing you do in Main so it's not usually going to be an issue, but Environment.Exit is not the cleanest form of flow control so I feel it deserves a mention.

Integrity Checking

One of the most important non-functional features of a library like this is making sure that you, the developer, don't have to test the input with various garbage to make sure you've implemented the library correctly. In that spirit, I've done my best to sanity check the Attributes you place on your options class when the parser is initialized (before any arguments are passed to it). You shouldn't have to wait until a user passes the wrong arguments in to discover that you've defined a duplicate argument short name or that the constant values you're storing aren't actually convertible to the property type.

Features

Named and Positional Arguments

There are two types of arguments that may be defined: Named and Positional arguments.

  • Named arguments are those set off by short names or long names (such as -v or --verbose). They are optional (in that the parser will not show an error if one is not specified) unless explicitly marked as required and may be given in any order (eg. the parser does not care if you use -v --input file.txt or --input file.txt -v).

  • Positional arguments, on the other hand, are always required and must be given in the correct order (indicated by the Index property when defining the argument). Since they have a defined order, there is no need to tag them with short names or long names: just put them in the argument list and they will be parsed automatically.

  • While positional arguments are required to be given in order, they may be freely intermixed with named arguments. For example, the following are all valid ways to call the example above and will give the same output:

    clipr.Sample.exe -v -v out.txt 2 3
    clipr.Sample.exe out.txt -v -v 2 3
    clipr.Sample.exe --verbose out.txt 2 -v 3
    
  • Since short arguments must be a single character, any short argument with an action that does not consume values (StoreConst, StoreTrue, StoreFalse, AppendConst, Count) may be combined together. -vvs is functionally the same as -v -v -s.

  • Similarly, short arguments with values may be input without a space between the flag and the first value (-fmyfilename.txt).

Multiple Action Types

Storing argument values isn't enough. There are a number of actions that can be performed when the user specifies a named argument.

  • Store: Exactly what you'd expect, this action stores the value(s) given after the argument in a property and is the default action.
  • StoreConst: Instead of letting the user choose which value is stored, a value stored in the Attribute's Const property will be used. Intended to avoid if(opts.FlagWasSet){ opts.SomeOtherProperty = MyConstantValue; } -- when parsing is finished, you should be ready to roll! Additionally, as long as the value stored in Const is convertible to the property type (through TypeDescriptor magic, more on that later), the property will be filled when the flag is set. Since Attribute properties may only be filled with compile-time constants, this allows you to use the same string serialization tricks for const arguments that you can use to transform argument strings into custom type objects.
  • StoreTrue: Shortcut for constructing a boolean StoreConst. Lets you specify command line flags that consume no values (eg. myprog.exe -v).
  • StoreFalse: The opposite of StoreTrue. Since booleans are value types with a default value of false already, it's worth noting here that nullable boolean properties work just fine, too (bool? MyFlag { get; set; }), which can be used to detect the presence of the "false" flag.
  • Append: Like Store, but if a user specifies the flag more than once the parsed values are appended to the existing ones rather than replacing them (eg. myprog.exe -f file1.txt -f file2.txt). The backing property must be some sort of IList.
  • AppendConst: Combine StoreConst and Append and you've got it.
  • Count: Counts the number of times the flag was specified as an argument. Good for specifying a "level" (like verbosity). There is no way to limit the number of times a user specifies the argument.

Variable/Optional Argument Count

In addition to choosing an action for each argument, you can specify how many values each argument can consume. The first part is the NumArgs property, which gives the value limit for the argument. The second part, constraints, comes in four flavors: Exact, AtLeast, AtMost, and Optional. Any named argument may take exactly the number of values specified or use that as the minimum or maximum number consumed. The final constraint, Optional, can be set on a reference type or Nullable property and it indicates that a value for the argument can be provided or left off. If the argument is given without a value, the value will be copied from the Const property on the argument. For more complex types, Const may be set to a string and the value will be converted to the destination type in the same way other argument values are converted.

[NamedArgument('s', "server", Constraint = NumArgsConstraint.Optional, Const = 1234)]
public int? Server { get; set; }

You may also use Optional arguments to define boolean flags that can easily be scripted (explicitly set to true or false).

private class Options
{
	[NamedArgument('f', "flag",
	   Constraint = NumArgsConstraint.Optional,
	   Action = ParseAction.Store,
	   Const = true)]
	public bool Flag { get; set; }

}

And call it with any of the following:

-f
-f true
-f false

Since positional arguments are not delimited in any discernable way only the last positional argument, by Index, may use the constraints AtLeast or AtMost. All previous positional arguments must consume an exact number of values.

Default Argument Values

Set default values for a property in the config object's constructor. If a value is provided on the command line, it will overwrite the default value.

public class Options
{
    public Options()
    {
        OutputFile = "out.txt";
    }

    [PositionalArgument(0, MetaVar = "OUT",
        Description = "Output file.")]
    public string OutputFile { get; set; }
}

Force Positional Argument Parsing

If, for any reason, you want the parser to stop parsing named arguments and count the rest as positional arguments, use a --. This is useful in cases where you want a positional argument that begins with a - (./prog.exe -- --sometext--) or when a named argument would otherwise consume your positional arguments as one of its own (./prog.exe --consumes-optional-value -- positional).

Password Masking

In some cases, it's useful to offer the ability for a user to "mask" their password, or provide it separately from the command line in order to prevent the password from appearing in console history or screenshots. This feature can be enabled on NamedArguments by annotating the property with a PromptValueIfMissingAttribute. You may also, optionally, choose to mask the password (so nothing shows on the screen when you type) or not. If the password is provided in the command line arguments, the value will used without prompting. If missing, the console will prompt for the missing input. Additionally, a SignalString can be provided to explicitly indicate that a value should be prompted. This resolves ambiguity in cases where a positional argument/verb could be mistaken for a value. Defaults to a dash -.

public class Options
{
    [PromptIfValueMissing(MaskInput = true)]
    [NamedArgument('s', "secret")]
    public string SecretValue { get; set; }
}
var opt = CliParser.Parse<Options>(new[] {"-s"});
public class OptionsWithPositional
{
    [PromptIfValueMissing(MaskInput = true)]
    [NamedArgument('s', "secret")]
    public string SecretValue { get; set; }

	[PositionalArgument(0)]
	public string Name { get; set; }
}
var opt = CliParser.Parse<OptionsWithPositional>(new[] {"-s - Nemec"});

Prints:

Input a value for -s:

This feature only applies in a specific set of circumstances:

  • Can only annotate a NamedArgument (not a PositionalArgument).
  • The ParseAction must be ParseAction.Store (the default).

Mutually Exclusive Arguments

Named arguments can be given a MutuallyExclusiveGroupAttribute. If multiple named arguments belong to the same group and the user tries to specify more than one, a parser error is generated. Groups can also be marked as Required. If at least one MutuallyExclusiveGroupAttribute for a group is required and the user does not provide one of the member arguments, an error is also generated.

Verbs

Verbs are the name given to a set of initial arguments that conditionally parse a set of configuration options based on the given verb. Think git add and git commit. Each has a different set of options that's used in a different way. Each verb class is a valid configuration class, so verbs are basically a way to combine multiple sets of configurations, conditionally, into one. Imagine that in addition to ./git, there was also ./git-add and ./git-commit each with its own configuration classes. With clipr, you can reuse those individual classes in ./git as verbs.

By default, the parser can automatically create an instance of each verb type as long as it has a parameterless default constructor. For more complex verb types, the CliParser constructor accepts an implementation of the clipr.IOC.IVerbFactory interface and will delegate to that interface when it needs to create a verb. Your choice of IOC Container can also be hooked into this Interface with an adapter. Also provided is a clipr.IOC.SimpleVerbFactory implementation that allows you to define a factory for each verb type in a collection initializer. The type in the initializer is optional and, if missing, will be inferred from the factory's return type.

var factory = new SimpleVerbFactory
{
	{ () => new GitAdd(".") }
	{ typeof(GitCommit), () => new GitCommit() }
}
public class GitOptions
{
	[Verb("add")]
	public GitAdd Add { get; set; }

	[Verb]  // The lowercased property name is used when no name is provided
	public GitCommit Commit { get; set; }
}

Some notes on verbs:

  • A configuration class cannot contain both positional parameters and verbs (although the verb itself may define its own positional parameters).
  • Verbs may be nested arbitrarily deep, so long as you adhere to the above requirement (although it's not recommended that you nest too deeply).
  • Multiple verb attributes may be registered for the same verb. These will act like aliases (svn co vs. svn checkout).
  • PostParse methods

Post-Parse Triggers

Using the PostParseAttribute you can mark parameterless methods to be automatically run once parsing is completed. When PostParse methods are nested within Verbs, they will be executed in order from innermost class to outermost, which means that whenever a PostParse method is executed, the configuration class and all its verbs will be fully initialized by that point.

Generated Help and Version Information

By default, Reflection is used to generate automatic help and version information based on the ArgumentAttributes you apply to the option class. Take the example above, this is the generated help documentation:

$ .\Clipr.Sample.exe --help
usage: clipr.Sample [ -v|--verbose ] OUT N ...

 This is a set of options.

positional arguments:
 N              Numbers to sum.
 OUT            Output file.

optional arguments:
 -h, --help     Display this help document.
 --version      Display version information.
 -v, --verbose  Increase the verbosity of the output.

You may also programmatically generate the help and usage for a class:

var parser = new CliParser<Options>(new Options());
var help = new AutomaticHelpGenerator<Options>();
// Gets the usage message, a short summary of available arguments.
Console.WriteLine(help.GetUsage(parser.Config));
// Gets the full help message with argument descriptions.
Console.WriteLine(help.GetHelp(parser.Config));

Version information is similarly auto-generated from the version string compiled into the application using this library. In the future there will be an easy way to specify the version manually, but until then you'll have to implement the IVersion interface yourself and replace the Version property within the IHelpGenerator.

Localization

clipr supports localization, both of the help UI generated by AutomaticHelpGenerator and the options themselves. Only the description of an argument is localizable - the long name of an argument cannot be localized to prevent issues if a shell script that called your application were run on a PC under a different locale.

Currently, the UI is only localized in three languages: English, Spanish, German, and Portuguese. If you're fluent in another language or see a problem with the existing translations, please consider adding a translation! More details can be found in issue #28.

To localize an argument description, apply the clipr.Attributes.LocalizeAttribute attribute to the property. Localization requires a strongly-typed Resources class, provided as the ResourceType property to the attribute. If this attribute is applied to the enclosing class, the ResourceType will be inherited by any properties within unless otherwise specified. If not provided, the ResourceName defaults to 'ClassName' if applied to a class or 'ClassName_PropertyName' if applied to a property.

[Localize(ResourceType = typeof(Properties.Resources))]
public class LocalizationOptions
{
    [Localize]  // Resource Name defaults to LocalizationOptions_TurnOnThePower
    [NamedArgument("turnonthepower", Action = ParseAction.StoreTrue)]
    public bool TurnOnThePower { get; set; }

    [Localize("FileToAdd", typeof(Properties.Resources))]
    [PositionalArgument(0)]
    public string FileToAdd { get; set; }
}

TypeDescriptor / TypeConverter

Custom types may be used as config values, but only if a TypeConverter is defined for that type. Since TypeConverters are big, messy beasts, and for purposes of this library there is only one conversion case (string -> MyType), the clipr.Utils namespace contains a StringTypeConverter that simplifies the conversion interface. After implementing the converter, tag your custom class with a TypeConverterAttribute to register the converter with .Net.

You may also attach a TypeConverter attribute to a Property on the class, which allows targeted conversion. It also allows you to apply custom converters to types that you do not own, like built-in classes.

Static Enumerations

Sometimes there is a need to add logic to the Enum type. Since .Net enums are purely integers, the only alternative is to implement the so-called "Typesafe Enum" pattern by creating a class with fields for each enum value, possibly with subclasses defining value-specific behavior.

Since clipr cannot rely on a specific typesafe enum implementation, it defines a common set of criteria for identifying and parsing them.

  1. Enum values must be public, static, and readonly fields of a class.
  2. Fields must be the same type as the underlying enum or a subclass.
  3. Static enums may only be parsed by name: the name of the field. Parsing is case insensitive (using the Invariant culture).
  4. The class must be tagged with the clipr.StaticEnumerationAttribute attribute. If the attribute cannot be added to the class directly (such as third party types), but the class follows (1) and (2), the attribute may also be applied to the option property.

Sample:

[StaticEnumeration]
internal abstract class SomeEnum
{
    public static readonly SomeEnum First = new EnumSubclass();

    public abstract void DoSomeWork();

    public class EnumSubclass : SomeEnum
    {
        public override void DoSomeWork() { }
    }
}

internal class StaticEnumerationOptions
{
    [NamedArgument('e')]
    public SomeEnum Value { get; set; } 
}

internal class StaticEnumerationExplicitOptions
{
    [NamedArgument('e')]
    [StaticEnumeration]  // Allowed in case attr is not defined on SomeEnum
    public SomeEnum Value { get; set; } 
}

public static void Main()
{
  var obj = CliParser.Parse<StaticEnumerationOptions>(
      "-e first".Split());
  Assert.AreSame(SomeEnum.First, obj.Value);
}

Fluent Interface

** Alpha Feature **

Instead of attributes, the parser may be configured using a fluent interface. There are five branches off of the parser to configure new arguments: HasNamedArgument, HasNamedArgumentList, HasPositionalArgument, HasPositionalArgumentList, and HasVerb. The argument instances returned from each of these may be used to configure the individual argument and you may return to the parser by chaining the And property when finished configuring.

var opt = new Options();

new CliParserBuilder<Options>(opt)
    .HasNamedArgument(o => o.Verbosity)
        .WithShortName('v')
        .CountsInvocations()
.And
    .HasNamedArgument(o => o.OutputFile)
        .WithShortName()
.And
    .HasPositionalArgumentList(o => o.Numbers)
        .HasDescription("These are numbers.")
        .Consumes.AtLeast(1)
.And.Parser
    .Parse("-vvv -o out.txt 3 4 5 6".Split());

Console.WriteLine(opt.Verbosity);
// >>> 3

Console.WriteLine(opt.OutputFile);
// >>> output.txt
    
var sum = 0;
foreach (var number in opt.Numbers)
{
    sum += number;
}
Console.WriteLine(sum);
// >>> 9

You may also add a Verb to the parser config with this syntax:

var opt = new Options();
new CliParserBuilder<Options>(opt)
    .HasVerb("add", c => c.AddVerb,
              // Note that in the Fluent Interface, you're nesting parsers
              // Theoretically this means you can nest an 
              // Attribute-configured parser inside a Fluent parser
              // (although you cannot do the opposite, due to limitations
              // with Attributes).
              new CliParserBuilder<AddFileOptions>(new AddFileOptions())
                  .HasPositionalArgument(c => c.Filename)
                  .And)  // A necessary evil if defining inline.
.And.Parser
    .Parse("add myfile.txt");

Console.WriteLine(opt.AddVerb);
// myfile.txt

Dictionary Backend

In addition to binding to a class (via properties), it is also possible to bind to keys in a dictionary by specifying the indexer (plus key name) in lieu of a property. The downside is that this requires all keys and all values to share the same type, respectively (the TypeConverter will leave the values in a Dictionary<string, object> as strings). Once you get past that, what this does is let you dynamically define the available configuration properties at runtime (which is as good as you're going to get, given that .Net currently doesn't allow ExpandoObject or dynamic in Expressions).

var key = 1;
var opt = new Dictionary<int, string>();
var parser = new CliParser<Dictionary<int, string>>(opt);
parser.HasNamedArgument(c => c[key])
        .WithShortName('n');

parser.Parse("-n frank".Split());

Console.WriteLine("Parsed Keys:");
foreach (var kv in opt)
{
    Console.WriteLine("\t{0}: {1}", kv.Key, kv.Value);
}
// Parsed Keys:
//    1: frank

Notes:

  • Any type with an indexer will work, it doesn't have to be a Dictionary.
  • Non-constant key expressions will be evaluated at configuration time (such as dict[MyProperty] or dict[MethodCall()]), so variables may also be used as keys without issue.
  • Since they're evaluated in configuration (rather than parsing), any type may be used as the indexer key, even if it's not convertible from a string.

TODO

Render help information for verbs