commandline icon indicating copy to clipboard operation
commandline copied to clipboard

Feature Request: Map Options to appsettings configuration

Open aaronenberg opened this issue 4 years ago • 1 comments

This is a feature that would allow overriding appsettings configuration with command line options.

This can be done by mapping an options ShortName/LongName to a configuration key. There is a basic way of doing this already with CommandLineConfigurationExtensions

AddCommandLine (this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, string[] args, System.Collections.Generic.IDictionary<string,string> switchMappings)

This adds a CommandLineConfigurationProvider that reads configuration values from the command line using the specified switch mappings.

The only issue is this does not work with bool options when specified without a true/false value as well as IEnumerable<T> Options.

I think this is a good candidate for becoming an UnParser extension. I’ll show exactly how I’ve implemented it in my project.

aaronenberg avatar Dec 28 '21 01:12 aaronenberg

Here's the attribute to decorate an Option which receives a sequence representing an appsettings configuration

[AttributeUsage(AttributeTargets.Property, Inherited = true)]
public sealed class OptionConfigurationAttribute : Attribute
{
    /// <summary>
    /// A sequence of configuration section names ending with the leaf
    /// configuration value name.
    /// </summary>
    public string[] ConfigurationPath { get; }

    public OptionConfigurationAttribute(params string[] configurationPath)
    {
        ConfigurationPath = configurationPath;
    }
}

Here is an example usage of the attribute:

[OptionConfiguration(nameof(MySection), nameof(MySection.EntityIds))]
[Option(longName: "ids", Required = false, Separator = ' ', HelpText = "Entity ids")]
public IEnumerable<int> EntityIds { get; }

[OptionConfiguration(nameof(MySection), nameof(MySection.IsDryRun))]
[Option(longName: "dry-run", Required = false, HelpText = "Print what the operation would do")]
public bool IsDryRun { get; }

Here's the extension I'm using to transform any type which has at least one property decorated with both attributes into an args sequence that can be fed to Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider

public static class OptionConfigurationParserExtensions
{
    /// <summary>
    /// Converts <paramref name="options"/> to a representation for
    /// <see cref="Microsoft.Extensions.Configuration.CommandLine.CommandLineConfigurationProvider"/>
    /// </summary>
    public static IEnumerable<string> ConvertToConfigurationArgs<T>(this Parser parser, T options)
        where T : class
    {
        var result = new List<string>();

        // To make the args uniform, strip any '=' and convert explicit
        // boolean parameters to implicit, i.e. just the switch.
        var args = parser.FormatCommandLineArgs(
            options,
            u => { u.UseEqualToken = false; u.SkipDefault = true; });

        var optionConfigurationProperties = GetOptionConfigurationProperties(typeof(T));
        var switchNameMappings = GetSwitchNameMappings(optionConfigurationProperties);

        PropertyInfo previousOption = null;
        var count = 0;
        for (var i = 0; i < args.Length; i++)
        {
            if (TryGetMappedOption(switchNameMappings, args[i], out var currentOption))
            {
                if (IsOnlyIEnumerableImplemented(currentOption))
                {
                    count = 0; // start counting the enumerable property's elements
                }
                else if (IsBool(currentOption))
                {
                    // because we removed default flags, a bool flag negates the default
                    result.Add(GetOptionConfigurationSwitch(currentOption));
                    result.Add($"{!GetBoolOptionDefault(currentOption)}");
                }
                else
                {
                    result.Add(GetOptionConfigurationSwitch(currentOption));
                }

                previousOption = currentOption;
            }
            else
            {
                if (previousOption != null && IsOnlyIEnumerableImplemented(previousOption))
                {
                    // each element of the enumerable needs its own indexed switch
                    var separator = GetOptionAttribute(previousOption).Separator;

                    if (char.IsWhiteSpace(separator))
                    {
                        result.Add(GetOptionConfigurationSwitch(previousOption) + ':' + count++);
                        result.Add(args[i]);
                    }
                    else
                    {
                        // handle separator which causes values to be a combined string
                        var argSplit = args[i].Split(separator);
                        foreach (var arg in argSplit)
                        {
                            result.Add(GetOptionConfigurationSwitch(previousOption) + ':' + count++);
                            result.Add(arg);
                        }
                    }
                }
                else // arg is an options's parameter, or, neither an option nor a parameter
                {
                    result.Add(args[i]);
                }
            }
        }

        return result;
    }

    /// <summary>
    /// Get a mapping of each <see cref="PropertyInfo"/> in
    /// <paramref name="propertyInfos"/> to their associated switch names
    /// </summary>
    private static IDictionary<PropertyInfo, IEnumerable<string>> GetSwitchNameMappings(IEnumerable<PropertyInfo> propertyInfos)
    {
        var result = new Dictionary<PropertyInfo, IEnumerable<string>>();

        foreach (var propertyInfo in propertyInfos)
        {
            var switchNames = GetSwitchNames(propertyInfo);
            result.Add(propertyInfo, switchNames);
        }

        return result;
    }

    /// <summary>
    /// Gets a sequence of switch names from <paramref name="propertyInfo"/>'s
    /// attribute of <see cref="OptionAttribute"/>
    /// </summary>
    private static IEnumerable<string> GetSwitchNames(PropertyInfo propertyInfo)
    {
        var switchNames = new List<string>();

        var optionAttribute = GetOptionAttribute(propertyInfo);

        // if OptionAttribute.LongName is null, then the name is inferred from the property name
        // https://github.com/commandlineparser/commandline/blob/master/src/CommandLine/OptionAttribute.cs
        var longName = optionAttribute.LongName;
        if (string.IsNullOrEmpty(longName))
            longName = propertyInfo.Name;

        switchNames.Add($"--{longName}");

        var shortName = optionAttribute.ShortName;
        if (!string.IsNullOrEmpty(shortName))
            switchNames.Add($"-{shortName}");

        return switchNames;
    }

    /// <summary>
    /// Tests whether the property's type implements
    /// <see cref="IEnumerable"/> and that is the only
    /// interface implemented.
    /// </summary>
    private static bool IsOnlyIEnumerableImplemented(PropertyInfo propertyInfo)
    {
        var propertyType = propertyInfo.PropertyType;

        return propertyType.GetInterfaces().Length == 1
            && typeof(IEnumerable).IsAssignableFrom(propertyType);
    }

    /// <summary>
    /// Tests whether the property's type is <see cref="bool"/>
    /// </summary>
    private static bool IsBool(PropertyInfo propertyInfo)
    {
        return propertyInfo.PropertyType == typeof(bool);
    }

    /// <summary>
    /// Gets the properties of <paramref name="type"/> which are decorated
    /// with both <see cref="OptionAttribute"/> and
    /// <see cref="OptionConfigurationAttribute"/>.
    /// </summary>
    private static IEnumerable<PropertyInfo> GetOptionConfigurationProperties(Type type)
    {
        IEnumerable<PropertyInfo> result = null;

        var properties = type.GetProperties();
        result = properties.Where(p =>
            p.GetCustomAttribute<OptionAttribute>() != null
            && p.GetCustomAttribute<OptionConfigurationAttribute>() != null);

        return result;
    }

    /// <summary>
    /// Gets the command-line switch representation of the property's
    /// <see cref="OptionConfigurationAttribute.ConfigurationPath"/>.
    /// </summary>
    private static string GetOptionConfigurationSwitch(PropertyInfo propertyInfo)
    {
        var optionConfigAttribute = GetOptionConfigurationAttribute(propertyInfo);
        var configurationPath = string.Join(":", optionConfigAttribute.ConfigurationPath);

        return $"--{configurationPath}";
    }

    /// <summary>
    /// Tests whether <paramref name="arg"/> is a mapped value in
    /// <paramref name="switchMappings"/> and if it is, gets the
    /// <see cref="PropertyInfo"/> key.
    /// </summary>
    private static bool TryGetMappedOption(
        IDictionary<PropertyInfo, IEnumerable<string>> switchMappings,
        string arg,
        out PropertyInfo propertyInfo)
    {
        propertyInfo = null;

        foreach (var switchMapping in switchMappings)
        {
            if (switchMapping.Value.Any(s => s == arg))
            {
                propertyInfo = switchMapping.Key;
                return true;
            }
        }

        return false;
    }

    private static bool GetBoolOptionDefault(PropertyInfo propertyInfo)
    {
        if (propertyInfo.PropertyType != typeof(bool))
            throw new ArgumentException("Property is not of type bool", nameof(propertyInfo));

        var optionAttribute = GetOptionAttribute(propertyInfo);

        var value = optionAttribute.Default as bool?;
        return value ?? default;
    }

    private static OptionAttribute GetOptionAttribute(PropertyInfo propertyInfo)
    {
        var optionAttribute = propertyInfo.GetCustomAttribute<OptionAttribute>();
        if (optionAttribute == null)
            throw new ArgumentException(
                $"Property does not have attribute {nameof(OptionAttribute)}", nameof(propertyInfo));

        return optionAttribute;
    }

    private static OptionConfigurationAttribute GetOptionConfigurationAttribute(PropertyInfo propertyInfo)
    {
        var optionConfigAttribute = propertyInfo.GetCustomAttribute<OptionConfigurationAttribute>();
        if (optionConfigAttribute == null)
            throw new ArgumentException(
                $"Property does not have attribute {nameof(OptionConfigurationAttribute)}", nameof(propertyInfo));

        return optionConfigAttribute;
    }
}

At the command line, the user would pass the options as usual:

app.exe getEntities --dry-run --ids 42 21

Which gets translated into this string sequence:

{ "getEntities", "--MySection:IsDryRun", "true", "--MySection:EntityIds:0", "42", "--MySection:EntityIds:1", "21"}

And that sequence can be passed to AddCommandLine (this Microsoft.Extensions.Configuration.IConfigurationBuilder configurationBuilder, string[] args) to override configuration settings.

aaronenberg avatar Jan 01 '22 16:01 aaronenberg