Open ptr727 opened 4 years ago
You can decouple the model names from the command line syntax currently, though the approach doesn't use attributes. It's intended also to support building your own conventions, so it might not be as concise as you like:
You can configure the model binder using middleware:
Regarding attributes specifically, one goal of System.CommandLine
is to provide a base layer for building conventions like you're describing. You can take a look at some work on these kinds of higher-level APIs here: https://github.com/KathleenDollard/command-line-api-starfruit. We'd appreciate feedback.
This model seems appealing: https://github.com/KathleenDollard/command-line-api-starfruit/blob/master/StarFruit/Program.cs
Regarding others building upon, agree, but the base needs to be 90% useful to most, and custom code for argument binding seems complex vs. attributes or other methods of self describing binding / option / command code in the base implementation.
Thank you for tagging as enhancement, will follow.
I just tried to start using System.CommandLine and immediately bumped into this. After adding 5 Option
s it quickly becomes unmanageable.
I need to propagate parsed options to many places in my logic code => inconvenient to have a dozen separate variables => need to encapsulate all variables into one UserOpts
object => have to manually maintain the mapping from all Option
s I add to Command
s into that custom UserOpts
object.
@chhh Can you give an example of the code? Propagating the Option
objects throughout your code is usually unnecessary if you're letting the model binder build your own custom types for you.
@jonsequitur I didn't know about "model binder". Armed with that new knowledge found some unit tests and came up with the following, which most closely resembles my typical usage of command line parsing, is that what you meant by "letting the model binder build your own custom types for you":
class UserOpts {
public int Size { get; set; }
public string Text { get; set; }
public double Radius { get; set; }
public override string ToString() {
return $"{nameof(Size)}: {Size}, {nameof(Text)}: {Text}, {nameof(Radius)}: {Radius}";
}
}
static RootCommand CreateRootCommand() {
var cmd = new RootCommand("test root command");
cmd.AddOption(new Option<int>(new[] {"-s", "--size"}) {Required = true});
cmd.AddOption(new Option<string>(new[] {"-t", "--text"}) {Required = false});
cmd.AddOption(new Option<double>(new[] {"-r", "--Radius"}) {Required = false});
return cmd;
}
static UserOpts ParseUserOpts(string[] args) {
var parser = new Parser(CreateRootCommand());
var parseResult = parser.Parse(args);
if (parseResult.Errors.Count > 0) {
var msg = String.Join(", ", parseResult.Errors.Select(err => err.Message).ToList());
throw new Exception(msg);
}
var bindingContext = new BindingContext(parseResult);
var binder = new ModelBinder(typeof(UserOpts));
var instance = binder.CreateInstance(bindingContext);
if (!(instance is UserOpts))
throw new InvalidOperationException("didn't get the user object from parser");
return (UserOpts)instance;
}
static void Main(string[] args) {
var opts = ParseUserOpts("-s 42 -t hello".Split(' '));
Console.WriteLine($"Parsed user opts object: {opts}");
}
@ptr727 I've found that if you assign Name
property of an Option
it can be used for binding to class. All three of the following:
new Option<string>(new[]{"-c", "--ccc"}){Required = false, Name = "binding-by-name"}
new Option<string>(new[]{"-b", "--bbb"}){Required = false}
new Option<string>(new[]{"-a", "--aaa"}){Required = true}
Will bind just fine to a class like:
class Opts {
public String BindingByName { get; set; }
public String B { get; set; }
public String Aaa { get; set; }
}
Implement an Attribute class
class OptionAttribute : Attribute
{
private string[] Alias { get; set; }
private string Description { get; set; }
public bool IsHidden { get; set; }
public OptionAttribute(string[] alias, string description = null, bool isHidden = false)
{
Alias = alias;
Description = description;
}
public Option GetOption(Type paramType)
{
var type = typeof(Option<>).MakeGenericType(paramType);
var option = Activator.CreateInstance(type, Alias, Description) as Option;
option.IsHidden = IsHidden;
return option;
}
}
Implement your complex options class.
public class Options
{
[Option(new[] { "--languageId", "-languageId" },
"LCID of language to use (if available), e.g. 0x409 for English")]
public int LanguageId { get; set; }
...
}
Get all options with given complex options class.
private IEnumerable<Option> GetOptions<T>()
{
List<Option> options = new List<Option>();
foreach (var pi in typeof(T).GetProperties())
{
var attr = pi.GetCustomAttribute<OptionAttribute>();
if (attr != null)
{
options.Add(attr.GetOption(pi.PropertyType));
}
}
return options;
}
@botcher We're working on approaches like this as an eventual expansion of the DragonFruit model, but using the C#9 source generator feature rather than reflection. We're typically avoided using reflection for configuring the parser due to performance concerns.
You can see more here: https://github.com/KathleenDollard/command-line-api-starfruit
It is great!
I am also struggling with this limitation. The solution suggested by @chhh has an unwanted side-effect (for me) that the Name is also mentioned as argument option in the help page. If that were not the case This would be anough to get model binding to act nice. I just set Name = nameof(Opts.BindingByName).
It looks like @botcher implemented an attribute,. I feel that is more a convenience method so you don't have to write any wode. This does not solve the problem with modelbinding where the argument "option" is -c
I'm not sure what the Name property is for but what @chhh suggest would work for me when the Name
would not get added to the help. The Name property would the function as a reflection helper to find/bind to the right property.
TLDR; I'd like to use attributes to do commandline parameter to complex class property name mapping.
I just ran into the problem of too many parameters in command handler functions. I found the complex object mapping proposed solution where I pass a config class with matching properties to the command handler.
Now I'm stuck using property names that match command attribute names, i.e. I want the commandline attributes to be concise, but I want my class naming to be descriptive.
Is it possible or planned to use attributes on the property names for matching, similar to e.g. JSON where the attribute describes the JSON node and the class code can be whatever?
I've used this method in other commandline mapping classes, but I'd like to standardize on this version as it seems to have a good chance of becoming a standard. See: https://github.com/commandlineparser/commandline
E.g.
Using attributes could also allow for defining parse handling, e.g. default value handling, required, optional, etc.
Is this supported, or is there an alternative easy way to instruct the parser which class properties to map to which commandline attributes?