Skip to content

Argument Models#

Argument models provide a way to define arguments in a class, allowing arguments to be reused and more easily passed to other methods.

Let's consider this notify command.

public void Notify(string message, List<string> recipients,
    [Option] bool dryrun, [Option('v')] bool verbose, [Option('q')] bool quiet)
{
    // send notification
}
snippet source | anchor

$ myapp.exe Notify --help
Usage: myapp.exe Notify [options] <message> <recipients>

Arguments:

  message                <TEXT>

  recipients (Multiple)  <TEXT>

Options:

  --dryrun

  -v | --verbose

  -q | --quiet
snippet source | anchor

In some cases, dryrun, verbose, and quite will be infrastructure concerns. For example...

  • A UnitOfWork middleware could commit or abort a transaction based on the dryrun value.
  • A Logging middleware could set the log level based on the verbose and quiet values.
  • We may want to assert verbose and quiet are mutually exclusive.

If we have multiple commands that use these same values, it's possible for them to be configured differently across commands. Take dryrun for example. Ask 5 different developers to add a dryrun option and you'll end up with 5 different casings for it. dryrun, dry-run, DryRun, Dryrun, ....

Use argument models to more easily reuse arguments, enforce consistency and make arguments easier to access from middleware.

Here's how they're configured

public void Notify(
    NotificationArgs notificationArgs,
    DryRunOptions dryRunOptions, 
    VerbosityOptions verbosityOptions)
{
    // send notification
}

public class NotificationArgs : IArgumentModel
{
    [Operand]
    public string Message { get; set; } = null!;

    [Operand]
    public List<string> Recipients { get; set; } = null!;
}
snippet source | anchor

public class DryRunOptions : IArgumentModel
{
    [Option("dryrun")]
    public bool IsDryRun { get; set; } = false;
}

public class VerbosityOptions : IArgumentModel, IValidatableObject
{
    [Option('v')]
    public bool Verbose { get; set; }

    [Option('q')]
    public bool Quite { get; set; }

    public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
    {
        // use the CommandDotNet.DataAnnotations package to run this validation
        if (Verbose && Quite)
            yield return new ValidationResult("Verbose and Quiet are mutually exclusive. Choose one or the other.");
    }
}
snippet source | anchor

$ myapp.exe Notify --help
Usage: myapp.exe Notify [options] <Message> <Recipients>

Arguments:

  Message                <TEXT>

  Recipients (Multiple)  <TEXT>

Options:

  --dryrun

  -v | --Verbose

  -q | --Quite
snippet source | anchor

Notice the help output is the same, except for the casing of the names derived from the properties. The casing can be fixed by providing a name in the Operand and Option attributes, using lowercase property names, or using the NameCasing package to convert all command and argument names to the same case.

Tip

See Nullable Reference Types for avoiding "Non-nullable property is uninitialized" warnings in your argument models

Composition#

An IArgumentModel can be composed from other IArgumentModels allowing easy reuse of common arguments.

Using same example from above, we configure the arguments into a single model like this...

public void Notify(NotificationArgs notificationArgs)
{
    // send notification
}

public class NotificationArgs : IArgumentModel
{
    [Operand]
    public string Message { get; set; } = null!;

    [Operand]
    public List<string> Recipients { get; set; } = null!;

    public DryRunOptions DryRunOptions { get; set; } = null!;

    public VerbosityOptions VerbosityOptions { get; set; } = null!;
}
snippet source | anchor

Accessing from interceptors and middleware#

Instead of defining the model in each command method, the model could be defined in an interceptor of the root command and be available for all commands.

public Task<int> Interceptor(InterceptorExecutionDelegate next, CommandContext ctx,
    DryRunOptions dryRunOptions, VerbosityOptions verbosityOptions)
{
    IEnumerable<IArgumentModel> models = ctx.InvocationPipeline.All
        .SelectMany(s => s.Invocation.FlattenedArgumentModels);
    return next();
}

public void Notify(NotificationArgs notificationArgs)
{
    // send notification
}

public class NotificationArgs : IArgumentModel
{
    [Operand]
    public string Message { get; set; } = null!;

    [Operand]
    public List<string> Recipients { get; set; } = null!;
}

public class DryRunOptions : IArgumentModel
{
    [Option("dryrun", AssignToExecutableSubcommands = true)]
    public bool IsDryRun { get; set; } = false;
}

public class VerbosityOptions : IArgumentModel
{
    [Option('v', AssignToExecutableSubcommands = true)]
    public bool Verbose { get; set; }

    [Option('q', AssignToExecutableSubcommands = true)]
    public bool Quite { get; set; }
}
snippet source | anchor

$ myapp.exe Notify --help
Usage: myapp.exe Notify [options] <Message> <Recipients>

Arguments:

  Message                <TEXT>

  Recipients (Multiple)  <TEXT>

Options:

  --dryrun

  -v | --Verbose

  -q | --Quite
snippet source | anchor

Notice the use of AssignToExecutableSubcommands=true in the Option attributes. This configures the option as if defined in the command methods. Without this setting, the user would provide the options in the command hosting the interceptor method.

Notice in the interceptor method how the list of all argument models can be retrieved from the pipeline of the targetted command. Middleware can use this to fetch an argument model.

Guaranteeing the order of arguments#

Prior to version 4, argument position is not guaranteed to be consistent because the .Net Framework does not guarantee the order properties are reflected.

The GetProperties method does not return properties in a particular order, such as alphabetical or declaration order. Order can differ on each machine the app is deployed to. Your code must not depend on the order in which properties are returned because that order is no guaranteed.

For Operands, which are positional arguments, this can result in commands with operands in a non-deterministic order.

This is not reliability issue with Option because options are named, not positional. The only impact is the order options appear in help.

As of version 4, CommandDotNet can guarantee all arguments will maintain their position as defined within a class as long as the properties are decorated with OperandAttribute, OptionAtribute or OrderByPositionInClassAttribute.

How to use#

The OperandAttribute and OptionAtribute define an optional constructor parameter called __callerLineNumber. This uses the CallerLineNumberAttribute to auto-assign the line number in the class. Do Not provide a value for this parameter.

CommandDotNet will raise an exception when the order cannot be determined, which occurs when either

  1. AppSettings.Arguments.DefaultArgumentMode == ArgumentMode.Operand (the default) and the property is not attributed
  2. When a nested argument model containing an operand is not decorated with [OrderByPositionInClass]

An example of invalidly nesting an IArgumentModel that contains operands

public class NotifyModel : IArgumentModel
{
    public NotificationArgs NotificationArgs { get; set; }
    public DryRunOptions DryRunOptions { get; set; }
    public VerbosityOptions VerbosityOptions { get; set; }
}
snippet source | anchor

And the error received because NotificationArgs contains operands

$ myapp.exe Notify --help
CommandDotNet.InvalidConfigurationException: Operand property must be attributed with OperandAttribute or OrderByPositionInClassAttribute to guarantee consistent order. Properties:
  CommandDotNet.DocExamples.Arguments.Arguments.Argument_Models+Program_WithInvalidhNestedOperands+NotifyModel.NotificationArgs
snippet source | anchor

This is the correct way to nest a model with operands

public class NotifyModel : IArgumentModel
{
    [OrderByPositionInClass]
    public NotificationArgs NotificationArgs { get; set; }
    public DryRunOptions DryRunOptions { get; set; }
    public VerbosityOptions VerbosityOptions { get; set; }
}
snippet source | anchor

Recommendation#

  • When possible, do not define operands in nested argument models.
  • Always attribute operands in properties with [Operand].