Feature Request: Map Options to appsettings configuration
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.
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.