Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Powershell module: Dynamic mandatory hierarchical parameters

Tags:

So what I really want is somewhat usable tab completion in a PS module. ValidateSet seems to be the way to go here.

Unfortunately my data is dynamic, so I cannot annotate the parameter with all valid values upfront. DynamicParameters/IDynamicParameters seems to be the solution for that problem.

Putting these things together (and reducing my failure to a simple test case) we end up with:

using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Management.Automation; using System.Text; using System.Threading.Tasks;  namespace PSDummy {     [Cmdlet(VerbsCommon.Get, "BookDetails")]     public class GetBookDetails : Cmdlet, IDynamicParameters     {         IDictionary<string, string[]> m_dummyData = new Dictionary<string, string[]> {             {"Terry Pratchett", new [] {"Small Gods", "Mort", "Eric"}},             {"Douglas Adams", new [] {"Hitchhiker's Guide", "The Meaning of Liff"}}         };          private RuntimeDefinedParameter m_authorParameter;         private RuntimeDefinedParameter m_bookParameter;          protected override void ProcessRecord()         {               // Do stuff here..         }          public object GetDynamicParameters()         {             var parameters = new RuntimeDefinedParameterDictionary();              m_authorParameter = CreateAuthorParameter();             m_bookParameter = CreateBookParameter();              parameters.Add(m_authorParameter.Name, m_authorParameter);             parameters.Add(m_bookParameter.Name, m_bookParameter);             return parameters;         }          private RuntimeDefinedParameter CreateAuthorParameter()         {             var p = new RuntimeDefinedParameter(                 "Author",                 typeof(string),                 new Collection<Attribute>                 {                     new ParameterAttribute {                         ParameterSetName = "BookStuff",                         Position = 0,                         Mandatory = true                     },                     new ValidateSetAttribute(m_dummyData.Keys.ToArray()),                     new ValidateNotNullOrEmptyAttribute()                 });              // Actually this is always mandatory, but sometimes I can fall back to a default             // value. How? p.Value = mydefault?              return p;         }          private RuntimeDefinedParameter CreateBookParameter()         {             // How to define a ValidateSet based on the parameter value for             // author?             var p = new RuntimeDefinedParameter(                 "Book",                 typeof(string),                 new Collection<Attribute>                 {                     new ParameterAttribute {                         ParameterSetName = "BookStuff",                         Position = 1,                         Mandatory = true                     },                     new ValidateSetAttribute(new string[1] { string.Empty }/* cannot fill this, because I cannot access the author */),                     new ValidateNotNullOrEmptyAttribute()                 });              return p;         }     } } 

Unfortunately this tiny snippet causes a lot of issues already. Ordered descending:

  • I fail to see how I can create a connection between the parameters. If you pick an author, you should only be able to pick a book that matches the author. So far GetDynamicParameters() always seems stateless though: I see no way to access the value of a different/earlier dynamic parameter. Tried keeping it in a field, tried searching MyInvocation - no luck. Is that even possible?

  • How do you define a default value for mandatory parameter? Doesn't fit the silly example, but let's say you can store your favorite author. From now on I want to default to that author, but having a pointer to an author is still mandatory. Either you gave me a default (and can still specify something else) or you need to be explicit.

  • Tab completion for strings with spaces seems weird/broken/limited - because it doesn't enclose the value with quotes (like cmd.exe would do, for example, if you type dir C:\Program <tab>). So tab completion actually breaks the invocation (if the issues above would be resolved, Get-BookDetails Ter<tab> would/will expand to Get-BookDetails Terry Pratchett which puts the last name in parameter position 1 aka 'book'.

Shouldn't be so hard, surely someone did something similar already?

Update: After another good day of tinkering and fooling around I don't see a way to make this work. The commandlet is stateless and will be instantiated over and over again. At the point in time when I can define dynamic parameters (GetDynamicParameters) I cannot access their (current) values/see what they'd be bound to - e.g. MyInvocation.BoundParameters is zero. I'll leave the question open, but it seems as if this just isn't supported. All the examples I see add a dynamic parameter based on the value of a static one - and that's not relevant here. Bugger.

like image 364
Benjamin Podszun Avatar asked Apr 13 '15 14:04

Benjamin Podszun


1 Answers

I think this works. Unfortunately, it uses reflection to get at some of the cmdlet's private members for your first bullet. I got the idea from Garrett Serack. I'm not sure if I completely understood how to do the default author, so I made it so that the last valid author is stored in a static field so you don't need -Author the next time.

Here's the code:

using System; using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; using System.Management.Automation; using System.Text; using System.Threading.Tasks;  namespace PSDummy {     internal class DynParamQuotedString {         /*             This works around the PowerShell bug where ValidateSet values aren't quoted when necessary, and             adding the quotes breaks it. Example:              ValidateSet valid values = 'Test string'  (The quotes are part of the string)              PowerShell parameter binding would interperet that as [Test string] (no single quotes), which wouldn't match             the valid value (which has the quotes). If you make the parameter a DynParamQuotedString, though,             the parameter binder will coerce [Test string] into an instance of DynParamQuotedString, and the binder will             call ToString() on the object, which will add the quotes back in.         */          internal static string DefaultQuoteCharacter = "'";          public DynParamQuotedString(string quotedString) : this(quotedString, DefaultQuoteCharacter) {}         public DynParamQuotedString(string quotedString, string quoteCharacter) {             OriginalString = quotedString;             _quoteCharacter = quoteCharacter;         }          public string OriginalString { get; set; }         string _quoteCharacter;          public override string ToString() {             // I'm sure this is missing some other characters that need to be escaped. Feel free to add more:             if (System.Text.RegularExpressions.Regex.IsMatch(OriginalString, @"\s|\(|\)|""|'")) {                 return string.Format("{1}{0}{1}", OriginalString.Replace(_quoteCharacter, string.Format("{0}{0}", _quoteCharacter)), _quoteCharacter);             }             else {                 return OriginalString;             }         }          public static string[] GetQuotedStrings(IEnumerable<string> values) {             var returnList = new List<string>();             foreach (string currentValue in values) {                 returnList.Add((new DynParamQuotedString(currentValue)).ToString());             }             return returnList.ToArray();         }     }       [Cmdlet(VerbsCommon.Get, "BookDetails")]     public class GetBookDetails : PSCmdlet, IDynamicParameters     {         IDictionary<string, string[]> m_dummyData = new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase) {             {"Terry Pratchett", new [] {"Small Gods", "Mort", "Eric"}},             {"Douglas Adams", new [] {"Hitchhiker's Guide", "The Meaning of Liff"}},             {"An 'Author' (notice the ')", new [] {"A \"book\"", "Another 'book'","NoSpace(ButCharacterThatShouldBeEscaped)", "NoSpace'Quoted'", "NoSpace\"Quoted\""}}   // Test value I added         };          protected override void ProcessRecord()         {             WriteObject(string.Format("Author = {0}", _author));             WriteObject(string.Format("Book = {0}", ((DynParamQuotedString) MyInvocation.BoundParameters["Book"]).OriginalString));         }          // Making this static means it should keep track of the last author used         static string _author;         public object GetDynamicParameters()         {             // Get 'Author' if found, otherwise get first unnamed value             string author = GetUnboundValue("Author", 0) as string;             if (!string.IsNullOrEmpty(author)) {                  _author = author.Trim('\'').Replace(                     string.Format("{0}{0}", DynParamQuotedString.DefaultQuoteCharacter),                      DynParamQuotedString.DefaultQuoteCharacter                 );              }              var parameters = new RuntimeDefinedParameterDictionary();              bool isAuthorParamMandatory = true;             if (!string.IsNullOrEmpty(_author) && m_dummyData.ContainsKey(_author)) {                 isAuthorParamMandatory = false;                 var m_bookParameter = new RuntimeDefinedParameter(                     "Book",                     typeof(DynParamQuotedString),                     new Collection<Attribute>                     {                         new ParameterAttribute {                             ParameterSetName = "BookStuff",                             Position = 1,                             Mandatory = true                         },                         new ValidateSetAttribute(DynParamQuotedString.GetQuotedStrings(m_dummyData[_author])),                         new ValidateNotNullOrEmptyAttribute()                     }                 );                  parameters.Add(m_bookParameter.Name, m_bookParameter);             }              // Create author parameter. Parameter isn't mandatory if _author             // has a valid author in it             var m_authorParameter = new RuntimeDefinedParameter(                 "Author",                 typeof(DynParamQuotedString),                 new Collection<Attribute>                 {                     new ParameterAttribute {                         ParameterSetName = "BookStuff",                         Position = 0,                         Mandatory = isAuthorParamMandatory                     },                     new ValidateSetAttribute(DynParamQuotedString.GetQuotedStrings(m_dummyData.Keys.ToArray())),                     new ValidateNotNullOrEmptyAttribute()                 }             );             parameters.Add(m_authorParameter.Name, m_authorParameter);              return parameters;         }          /*             TryGetProperty() and GetUnboundValue() are from here: https://gist.github.com/fearthecowboy/1936f841d3a81710ae87             Source created a dictionary for all unbound values; I had issues getting ValidateSet on Author parameter to work             if I used that directly for some reason, but changing it into a function to get a specific parameter seems to work         */          object TryGetProperty(object instance, string fieldName) {             var bindingFlags = System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public;              // any access of a null object returns null.              if (instance == null || string.IsNullOrEmpty(fieldName)) {                 return null;             }              var propertyInfo = instance.GetType().GetProperty(fieldName, bindingFlags);              if (propertyInfo != null) {                 try {                     return propertyInfo.GetValue(instance, null);                 }                  catch {                 }             }              // maybe it's a field             var fieldInfo = instance.GetType().GetField(fieldName, bindingFlags);              if (fieldInfo!= null) {                 try {                     return fieldInfo.GetValue(instance);                 }                  catch {                 }             }              // no match, return null.             return null;         }          object GetUnboundValue(string paramName) {             return GetUnboundValue(paramName, -1);         }          object GetUnboundValue(string paramName, int unnamedPosition) {              // If paramName isn't found, value at unnamedPosition will be returned instead             var context = TryGetProperty(this, "Context");             var processor = TryGetProperty(context, "CurrentCommandProcessor");             var parameterBinder = TryGetProperty(processor, "CmdletParameterBinderController");             var args = TryGetProperty(parameterBinder, "UnboundArguments") as System.Collections.IEnumerable;              if (args != null) {                 var currentParameterName = string.Empty;                 object unnamedValue = null;                 int i = 0;                 foreach (var arg in args) {                     var isParameterName = TryGetProperty(arg, "ParameterNameSpecified");                     if (isParameterName != null && true.Equals(isParameterName)) {                         string parameterName = TryGetProperty(arg, "ParameterName") as string;                         currentParameterName = parameterName;                          continue;                     }                      // Treat as a value:                     var parameterValue = TryGetProperty(arg, "ArgumentValue");                      if (currentParameterName != string.Empty) {                         // Found currentParameterName's value. If it matches paramName, return                         // it                         if (currentParameterName.Equals(paramName, StringComparison.OrdinalIgnoreCase)) {                             return parameterValue;                         }                     }                     else if (i++ == unnamedPosition) {                         unnamedValue = parameterValue;  // Save this for later in case paramName isn't found                     }                      // Found a value, so currentParameterName needs to be cleared                     currentParameterName = string.Empty;                 }                  if (unnamedValue != null) {                     return unnamedValue;                 }             }              return null;         }     } } 
like image 83
Rohn Edwards Avatar answered Oct 18 '22 16:10

Rohn Edwards