Looping Register-ArgumentCompleter produces incorrect parameter completions - powershell

I have a module with a hashtable of dynamically derived enum values, that I thought would be slick to incorporate into Register-ArgumentCompleter for tab completion.
The motivation here is that I can't directly set the module function's input parameters to autoconvert into the enum type (which would properly enable tab completion), because I wish to dynamically derive the enums to save users from manually managing the enum values, as well as due to limitations with the .NET implementation of enums -- I need to allow for strings with dashes or starting with numbers, and potentially null values, all of which enums sadly don't allow. My idea is to do a workaround by adding tab-completed parameter values via Register-ArgumentCompleter.
Problem: I build this workaround as a script that's loaded in the first position of the ScriptsToProcess member of the module manifest, whereupon I discovered that incorrect values are being set when I loop over the hashtable keys and run Register-ArgumentCompleter.
Sample code to reproduce:
function test {param($a, $b, $c, $d )}
$ht = #{
'1' = #('a', #('a1','a2'))
'2' = #('b', #('b1','b2'))
'3' = #('c', #('c1','c2'))
'4' = #('d', #('d1','d2'))
}
Foreach ($enum in $ht.Keys){
$paramName = $ht.$enum[0]
$paramValue = $ht.$enum[1]
write-host $paramName
write-host $paramValue
Register-ArgumentCompleter -CommandName test2 -ParameterName $paramName -ScriptBlock {$paramValue}
}
PS> test -a <tab>
b1 b2
This is PS 7.2.5. In Windows PowerShell 5.1.19041 I get c1 c2 as suggested values. You can see from the host writes that it's down to whichever key is parsed last in the ht loop.
I also tried $ht.["$enum"][0|1] to cast the key type explicitly to a string, to no avail. When I write-host in the loop, all the values seem correct.
Does this seem like an error from me or a bug?

By the time the loop completes, $enum will have a value of whatever the last key in its sort order is.
Use ScriptBlock.GetNewClosure() to close over the value of $ht and $enum by the time GetNewClosure() is called, making the scriptblock retain the original values of $ht and $enum:
function test {param($a, $b, $c, $d )}
$ht = #{
'1' = #('a', #('a1','a2'))
'2' = #('b', #('b1','b2'))
'3' = #('c', #('c1','c2'))
'4' = #('d', #('d1','d2'))
}
Foreach ($enum in $ht.Keys){
Register-ArgumentCompleter -CommandName test -ParameterName $ht.$enum[0] -ScriptBlock { $ht.$enum[1] }.GetNewClosure()
}
FWIW you can simplify the $ht table significantly:
$ht = #{
'a' = #('a1','a2')
'b' = #('b1','b2')
'c' = #('c1','c2')
'd' = #('d1','d2')
}
Foreach ($enum in $ht.Keys){
Register-ArgumentCompleter -CommandName test -ParameterName $enum -ScriptBlock { $ht[$enum] }.GetNewClosure()
}

Related

How to loop through arrays in hash table - passing parameters based on values read from a CSV file

Curious about how to loop through a hash table where each value is an array. Example:
$test = #{
a = "a","1";
b = "b","2";
c = "c","3";
}
Then I would like to do something like:
foreach ($T in $test) {
write-output $T
}
Expected result would be something like:
name value
a a
b b
c c
a 1
b 2
c 3
That's not what currently happens and my use case is to basically pass a hash of parameters to a function in a loop. My approach might be all wrong, but figured I would ask and see if anyone's tried to do this?
Edit**
A bit more clarification. What I'm basically trying to do is pass a lot of array values into a function and loop through those in the hash table prior to passing to a nested function. Example:
First something like:
$parameters = import-csv .\NewComputers.csv
Then something like
$parameters | New-LabVM
Lab VM Code below:
function New-LabVM
{
[CmdletBinding()]
Param (
# Param1 help description
[Parameter(Mandatory=$true,
Position=0,
ValueFromPipeline=$true,
ValueFromPipelineByPropertyName=$true)]
[Alias("p1")]
[string[]]$ServerName,
# Param2 help description
[Parameter(Position = 1)]
[int[]]$RAM = 2GB,
# Param3 help description
[Parameter(Position=2)]
[int[]]$ServerHardDriveSize = 40gb,
# Parameter help description
[Parameter(Position=3)]
[int[]]$VMRootPath = "D:\VirtualMachines",
[Parameter(Position=4)]
[int[]]$NetworkSwitch = "VM Switch 1",
[Parameter(Position=4)]
[int[]]$ISO = "D:\ISO\Win2k12.ISO"
)
process
{
New-Item -Path $VMRootPath\$ServerName -ItemType Directory
$Arguments = #{
Name = $ServerName;
MemoryStartupBytes = $RAM;
NewVHDPath = "$VMRootPath\$ServerName\$ServerName.vhdx";
NewVHDSizeBytes = $ServerHardDriveSize
SwitchName = $NetworkSwitch;}
foreach ($Argument in $Arguments){
# Create Virtual Machines
New-VM #Arguments
# Configure Virtual Machines
Set-VMDvdDrive -VMName $ServerName -Path $ISO
Start-VM $ServerName
}
# Create Virtual Machines
New-VM #Arguments
}
}
What you're looking for is parameter splatting.
The most robust way to do that is via hashtables, so you must convert the custom-object instances output by Import-Csv to hashtables:
Import-Csv .\NewComputers.csv | ForEach-Object {
# Convert the custom object at hand to a hashtable.
$htParams = #{}
$_.psobject.properties | ForEach-Object { $htParams[$_.Name] = $_.Value }
# Pass the hashtable via splatting (#) to the target function.
New-LabVM #htParams
}
Note that since parameter binding via splatting is key-based (the hashtable keys are matched against the parameter names), it is fine to use a regular hashtable with its unpredictable key ordering (no need for an ordered hashtable ([ordered] #{ ... }) in this case).
Try this:
for($i=0;$i -lt $test.Count; $i++)
{$test.keys | %{write-host $test.$_[$i]}}
Weirdly, it outputs everything in the wrong order (because $test.keys outputs it backwards).
EDIT: Here's your solution.
Using the [System.Collections.Specialized.OrderedDictionary] type, you guarantee that the output will come out the same order as you entered it.
$test = [ordered] #{
a = "a","1";
b = "b","2";
c = "c","3";
}
After running the same solution code as before, you get exactly the output you wanted.

PowerShell: Invoking a script block that contains underscore variable

I normally do the following to invoke a script block containing $_:
$scriptBlock = { $_ <# do something with $_ here #> }
$theArg | ForEach-Object $scriptBlock
In effect, I am creating a pipeline which will give $_ its value (within the Foreach-Object function invocation).
However, when looking at the source code of the LINQ module, it defines and uses the following function to invoke the delegate:
# It is actually surprisingly difficult to write a function (in a module)
# that uses $_ in scriptblocks that it takes as parameters. This is a strange
# issue with scoping that seems to only matter when the function is a part
# of a module which has an isolated scope.
#
# In the case of this code:
# 1..10 | Add-Ten { $_ + 10 }
#
# ... the function Add-Ten must jump through hoops in order to invoke the
# supplied scriptblock in such a way that $_ represents the current item
# in the pipeline.
#
# Which brings me to Invoke-ScriptBlock.
# This function takes a ScriptBlock as a parameter, and an object that will
# be supplied to the $_ variable. Since the $_ may already be defined in
# this scope, we need to store the old value, and restore it when we are done.
# Unfortunately this can only be done (to my knowledge) by hitting the
# internal api's with reflection. Not only is this an issue for performance,
# it is also fragile. Fortunately this appears to still work in PowerShell
# version 2 through 3 beta.
function Invoke-ScriptBlock {
[CmdletBinding()]
param (
[Parameter(Position=1,Mandatory=$true)]
[ScriptBlock]$ScriptBlock,
[Parameter(ValueFromPipeline=$true)]
[Object]$InputObject
)
begin {
# equivalent to calling $ScriptBlock.SessionState property:
$SessionStateProperty = [ScriptBlock].GetProperty('SessionState',([System.Reflection.BindingFlags]'NonPublic,Instance'))
$SessionState = $SessionStateProperty.GetValue($ScriptBlock, $null)
}
}
process {
$NewUnderBar = $InputObject
$OldUnderBar = $SessionState.PSVariable.GetValue('_')
try {
$SessionState.PSVariable.Set('_', $NewUnderBar)
$SessionState.InvokeCommand.InvokeScript($SessionState, $ScriptBlock, #())
}
finally {
$SessionState.PSVariable.Set('_', $OldUnderBar)
}
}
}
This strikes me as a bit low-level. Is there a recommended, safe way of doing this?
You can invoke scriptblocks with the ampersand. No need to use Foreach-Object.
$scriptblock = {## whatever}
& $scriptblock
#(1,2,3) | % { & {write-host $_}}
To pass parameters:
$scriptblock = {write-host $args[0]}
& $scriptblock 'test'
$scriptBlock = {param($NamedParam) write-host $NamedParam}
& $scriptBlock -NamedParam 'test'
If you're going to be using this inside of Invoke-Command, you could also usin the $using construct.
$test = 'test'
$scriptblock = {write-host $using:test}

When does Powershell honour default values when using $null splat parameters?

Consider the following function:
function f1{
param(
$sb = {},
$s = ''
)
if ($sb -isnot [scriptblock]) { 'scriptblock' }
if ($s -isnot [string] ) { 'string' }
}
Now invoke it with a splat parameter:
PS C:\> $splat = #{foo='bar'}
PS C:\> f1 #splat
As expected, nothing is returned. Now try it again with a $null splat parameter:
PS C:\> $splat = $null
PS C:\> f1 #splat
scriptblock
Oddly, scriptblock is returned. Clearly, at least for the [scriptblock] parameter, powershell is not honoring the default value when a $null splat parameter is used. But powershell does honor the default value for the [string]. What is going on here?
For what types does Powershell honour default values when using $null splat parameters?
Isn't this just normal application of positional parameters? You are splatting a single $null which is being applied to $sb.
Compare:
> function f{ param($sb = {}, $s = '') $PSBoundParameters }
> $splat = #(1,2)
> f #splat
Key Value
--- -----
sb 1
s 2
> f #flkejlkfja
Key Value
--- -----
sb
> function f{ param($aaa = 5, $sb = {}, $s = '') $PSBoundParameters }
> f #splat
Key Value
--- -----
aaa 1
sb 2
It's an old question but if it is still interesting...
As others have written with $splat = $null calling f1 #splat the first parameters will get the value $null instead it's default value.
If you want the parameters use their default value in this case you have to use $splat = #{} or $splat = #().
Here's a demonstration to help understand what's happening
$splat = #{foo='bar'}
"$(&{$args}#splat)"
-foo: bar
When you splat the hash table, it gets converted to -Key: Value string pairs that become the parameters to your function.
Now try:
$splat = $null
"$(&{$args}#splat)"
Nothing is returned. There are no keys to generate the parameter string from, so the end result is the same as not passing any parameters at all.
To complement Etan Reisner's helpful answer with a more direct demonstration that splatting $null indeed passes $null as the first (and only) positional argument:
$splat = $null
& { [CmdletBinding(PositionalBinding=$False)] param($dummy) } #splat
The above yields the following error:
A positional parameter cannot be found that accepts argument '$null'.
...
Decorating the param() block with [CmdletBinding(PositionalBinding=$False)] ensures that only named parameter values can be passed, causing the positional passing of $null from splatting to trigger the error above.
Note that using the special "null collection" value ([System.Management.Automation.Internal.AutomationNull]::Value) that you get from commands that produce no output for splatting is effectively the same as splatting $null, because that "null collection" value is converted to $null during parameter binding.
VargaJoe's helpful answer explains how to construct a variable for splatting so that no arguments are passed, so that the callee's default parameter values are honored.

Invoke method on ScriptBlock truncating array

I have the following ScriptBlock defined:
[ScriptBlock]$strSb = {
param(
[Parameter(Mandatory=$false,Position=0)]
[String[]]$Modules = #('String3','String4')
)
Write-Host "Passed in params:"
foreach($m in $Modules){
Write-Host $m
}
$defaultModules = #('String3','String4')
# Add Default Modules back if not present #
foreach($module in $defaultModules){
if($Modules -notcontains $module){
$Modules += $module
}
}
Write-Host "Final set:"
# Load Dependencies #
foreach($m in $Modules){
Write-Host $m
}
}
As the parameter states in the ScriptBlock, I want to be able to pass in an array of strings. When I call $strSb.Invoke(#('String11','String12')) I receive the following:
Passed in params:
String11
Final set:
String11
String3
String4
What I expect is:
Passed in params:
String11
String12
Final set:
String11
String12
String3
String4
Why is the invoke method truncating my array to the first item entered? And how would I go about fixing it so I can pass in an array of strings?
FWIW: I'm working in v2 and v3.
The problem is that the Invoke method takes an array of arguments (kind of like commands that have an -ArgumentList parameter), so each element in your array is parsed as a separate argument. The first argument, 'String11', is assigned to the first postitional parameter, $Modules, and any subsequent arguments are discarded, since there are no more positional parameters. It doesn't matter that $Modules is declared as a string array; since each element of the argument list is a separate argument, you're setting $Modules to an array of one element.
If you use the , operator to indicate that you're passing in a single array argument, it works as intended:
$strSb.Invoke((,#('String11','String12')))
BTW, you don't really need the #, because a comma-separated list of strings is interpreted as an array by default. Not just in this particular context, but in general. So just use this:
$strSb.Invoke((,('String11','String12')))
To prove out the explanation above, try this scriptblock, which is the same except that a second parameter (creatively named $SecondParameter) is declared, and then displayed after the loop that displays the value of the first parameter:
[ScriptBlock]$strSb = {
param(
[Parameter(Mandatory=$false,Position=0)]
[String[]]$Modules = #('String3','String4'),
[String]$SecondParameter
)
Write-Host "Passed in params:"
foreach($m in $Modules){
Write-Host $m
}
Write-Host "`nSecondParameter: $SecondParameter`n"
$defaultModules = #('String3','String4')
# Add Default Modules back if not present #
foreach($module in $defaultModules){
if($Modules -notcontains $module){
$Modules += $module
}
}
Write-Host "Final set:"
# Load Dependencies #
foreach($m in $Modules){
Write-Host $m
}
}
If you then pass in the arguments as you were, $strSb.Invoke(#('String11','String12')), you get these results:
11-26-13 19:02:12.55 D:\Scratch\soscratch» $strSb.Invoke(#('String11','String12'))
Passed in params:
String11
SecondParameter: String12
Final set:
String11
String3
String4
11-26-13 19:02:29.34 D:\Scratch\soscratch»
One last tip, not directly related to the question, is that you can compact the foreach loops by using a pipelines, which is are not only more succinct but generally more efficient. Here's a compacted version of your code:
[ScriptBlock]$strSb = {
param(
[Parameter(Mandatory=$false,Position=0)]
[String[]]$Modules = ('String3','String4')
)
Write-Host "Passed in params:"
$Modules | Write-Host
$defaultModules = 'String3','String4'
# Add Default Modules back if not present #
$defaultModules | ?{$Modules -notcontains $_} | %{$Modules += $_}
Write-Host "Final set:"
# Load Dependencies #
$Modules | Write-Host
}
If I understand what you're doing, you want to take 2 arrays, concatenate them, and ensure uniqueness...
First, Since you have a [Parameter...] on your parameter, you magically get [CmdletBinding()] on the method. This means that you are automatically going to get $Modules split into multiple calls.
Second, ScriptBlock.Invoke() takes a params style array and puts them into the method as separate arguments.
The first thing I would try is to add the attribute to gather all values:
[Parameter(ValueFromRemainingArguments=$true, Position=0, Mandatory=$true)]
[String[]]$Modules
However, for the Join, you can much more easily do something like:
($modules + $defaultModules) | Select -Unique
Not sure exactly why, but it doesn't seem to like that named parameter. Seems to like $args, tho :
[ScriptBlock]$strSb = {
$Modules = $args
Write-Host "Passed in params:"
foreach($m in $modules){
Write-Host $m
}
$defaultModules = #('String3','String4')
# Add Default Modules back if not present #
foreach($module in $defaultModules){
if($Modules -notcontains $module){
$Modules += $module
}
}
Write-Host "Final set:"
# Load Dependencies #
foreach($m in $Modules){
Write-Host $m
}
}
$strSb.Invoke('String11','String12')
Passed in params:
String11
String12
Final set:
String11
String12
String3
String4

Setting a global PowerShell variable from a function where the global variable name is a variable passed to the function

I need to set a global variable from a function and am not quite sure how to do it.
# Set variables
$global:var1
$global:var2
$global:var3
function foo ($a, $b, $c)
{
# Add $a and $b and set the requested global variable to equal to it
$c = $a + $b
}
Call the function:
foo 1 2 $global:var3
End result:
$global:var3 is set to 3
Or if I called the function like this:
foo 1 2 $global:var2
End result:
$global:var2 is set to 3
I hope this example makes sense. The third variable passed to the function is the name of the variable it is to set.
You can use the Set-Variable cmdlet. Passing $global:var3 sends the value of $var3, which is not what you want. You want to send the name.
$global:var1 = $null
function foo ($a, $b, $varName)
{
Set-Variable -Name $varName -Value ($a + $b) -Scope Global
}
foo 1 2 var1
This is not very good programming practice, though. Below would be much more straightforward, and less likely to introduce bugs later:
$global:var1 = $null
function ComputeNewValue ($a, $b)
{
$a + $b
}
$global:var1 = ComputeNewValue 1 2
As simple as:
$A="1"
function changeA2 () { $global:A="0"}
changeA2
$A
I ran across this question while troubleshooting my own code.
So this does NOT work...
$myLogText = ""
function AddLog ($Message)
{
$myLogText += ($Message)
}
AddLog ("Hello")
Write-Host $myLogText
This APPEARS to work, but only in the PowerShell ISE:
$myLogText = ""
function AddLog ($Message)
{
$global:myLogText += ($Message)
}
AddLog ("Hello")
Write-Host $myLogText
This is actually what works in both ISE and command line:
$global:myLogText = ""
function AddLog ($Message)
{
$global:myLogText += ($Message)
}
AddLog ("Hello")
Write-Host $global:myLogText
You'll have to pass your arguments as reference types.
#First create the variables (note you have to set them to something)
$global:var1 = $null
$global:var2 = $null
$global:var3 = $null
#The type of the reference argument should be of type [REF]
function foo ($a, $b, [REF]$c)
{
# add $a and $b and set the requested global variable to equal to it
# Note how you modify the value.
$c.Value = $a + $b
}
#You can then call it like this:
foo 1 2 [REF]$global:var3
The first suggestion in latkin's answer seems good, although I would suggest the less long-winded way below.
PS c:\temp> $global:test="one"
PS c:\temp> $test
one
PS c:\temp> function changet() {$global:test="two"}
PS c:\temp> changet
PS c:\temp> $test
two
His second suggestion however about being bad programming practice, is fair enough in a simple computation like this one, but what if you want to return a more complicated output from your variable? For example, what if you wanted the function to return an array or an object? That's where, for me, PowerShell functions seem to fail woefully. Meaning you have no choice other than to pass it back from the function using a global variable. For example:
PS c:\temp> function changet([byte]$a,[byte]$b,[byte]$c) {$global:test=#(($a+$b),$c,($a+$c))}
PS c:\temp> changet 1 2 3
PS c:\temp> $test
3
3
4
PS C:\nb> $test[2]
4
I know this might feel like a bit of a digression, but I feel in order to answer the original question we need to establish whether global variables are bad programming practice and whether, in more complex functions, there is a better way. (If there is one I'd be interested to here it.)
#zdan. Good answer. I'd improve it like this...
I think that the closest you can come to a true return value in PowerShell is to use a local variable to pass the value and never to use return as it may be 'corrupted' by any manner of output situations
function CheckRestart([REF]$retval)
{
# Some logic
$retval.Value = $true
}
[bool]$restart = $false
CheckRestart( [REF]$restart)
if ( $restart )
{
Restart-Computer -Force
}
The $restart variable is used either side of the call to the function CheckRestart making clear the scope of the variable. The return value can by convention be either the first or last parameter declared. I prefer last.
Set the variable as a global variable outside of the function and then set the value inside of the function.