Function with undeclared parameters having priority over declared - powershell

I want to create log function that works similar to Write-Host in a manner that I can give it ad hoc arguments along with some parameters:
function log ( [int]$ident=0, [switch]$notime) {
$now = (Get-Date).ToString('s')
Write-Host $(if (!$NoTime) {now}) $($args | % { ' '*$ident*2 + $_ })
}
log 'test 1' 'test 2' # Cannot convert value "test 1" to type "System.Int32"
log 'test 1' 'test 2' -Ident 1 #Works
I know that I can get undeclared args with $args or use ValueFromRemainingArguments attribute but that requires me to change the way the function is called as declared function parameters will collect them.

Turn off positional binding:
function log {
[CmdletBinding(PositionalBinding=$False)]
param (
[int]$indent=0,
[switch]$notime,
[Parameter(ValueFromRemainingArguments)]
[string[]] $rest
)
$now = (Get-Date).ToString('s')
Write-Host $(if (!$NoTime) {$now}) $($rest | % { ' '*$indent*2 + $_ })
}
Note that, obviously, you are now required to include the names of the parameters, but that should be a good thing (it would be confusing to have to know whether 1 is the value of indent or the first value to log).

Related

Powershell splatting a nested hash table

I have a function that returns a complex nested hash table data structure, where part of it forms the arguments for a further function call, and part of it is strings for reporting errors that result in the arguments not being populated.
Ideally I would like to splat just the arguments hash table, but I am starting to think that can't be done.
So, an example of the basic problem looks like this...
function Test {
param (
[String]$A,
[String]$B,
[String]$C
)
Write-Host "A: $A"
Write-Host "B: $B"
Write-Host "C: $C"
}
$data = #{
arguments = #{
A = 'A string'
B = 'Another string'
C = 'The last string'
}
kruft = 'A string that doesn not need to get passed'
}
Ideally I want to splat $data.arguments, so something like this...
Test #data.arguments
But that doesn't work, resulting in the error
The splatting operator '#' cannot be used to reference variables in an expression. '#data' can be used only as an argument to a command. To
reference variables in an expression use '$data'.
So I tried...
Test #(data.arguments)
Which results in the error
data.arguments : The term 'data.arguments' is not recognized as the name of a cmdlet, function, script file, or operable program. Check the
spelling of the name, or if a path was included, verify that the path is correct and try again.
I also tried...
Test #($data.arguments)
Which results in the whole hash table being passed as a single argument and the output is
A: System.Collections.Hashtable
B:
C:
What DOES work is...
$arguments = $data.arguments
Test #arguments
Which has me thinking you really cannot splat anything but a simple variable that is an appropriate hash table. But, I am hoping someone can verify that is indeed true, or point out the solution I haven't come up with yet.
The actual code requires 5 arguments, with somewhat verbose names because I prefer descriptive names, so splatting is very much an appropriate solution. Needing to make a new variable with just the hash table to be passed isn't an issue, just wondering if it really is the only option.
That's not possible.
Any variable (and only variables) provided with "#" instead of "$" for a function parameter is declared as TokenKind "SplattedVariable".
See:
https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.language.tokenkind?view=powershellsdk-7.0.0
PS:
Some quick tests from my side which could have succeed (apart from PS design):
Write-Host 'Test 1' -ForegroundColor Yellow
Test #$data.arguments
Write-Host 'Test 2' -ForegroundColor Yellow
Test #$($bla = $data.arguments)
Write-Host 'Test 3' -ForegroundColor Yellow
Test #$bla = $data.arguments
Write-Host 'Test 4' -ForegroundColor Yellow
Test #$bla = $data.arguments.GetEnumerator()
Write-Host 'Test 5' -ForegroundColor Yellow
Test #$($data.arguments.GetEnumerator())
... but they didn't.
I cannot claim to fully understand what you're attempting, but here are a couple of solutions that might help.
Your Test function is expecting three strings, but I don't see anything in your example that satisfies that. I would either change it to accept a Hashtable (which is the data type in question) or have it accept a string array and pass $data.arguments.values
Using a Hashtable:
function Test {
param (
[Hashtable]$hash
)
write-host $hash.A
write-host $hash.B
write-host $hash.C
}
Test $data.arguments
Using a String array:
function Test {
param (
[String[]]$a
)
$a | % { write-host $_ }
}
Test $data.arguments.values
This is a totally artificial way (more of a hack) of doing this but you could pass it as a hash table then self-call with the arguments splatted
function Test {
param (
[Parameter()]
[hashtable]$ht,
[String]$A,
[String]$B,
[String]$C
)
if($null -eq $ht){
Write-Host "A: $a"
Write-Host "B: $B"
Write-Host "C: $C"
}
else
{
Write-Host "ht.A: $($ht.a)"
Test -a $($ht.A+ " new blob") -b $ht.B -c $ht.C
Test #ht
}
}
$tdata = #{
arguments = #{
A = 'A string'
B = 'Another string'
C = 'The last string'
}
kruft = 'A string that doesn not need to get passed'
}
Write-Host 'Test ht' -ForegroundColor Yellow
Test -ht $tdata.arguments

Why can't I use $_ in write-host?

I am trying to pipe an array of strings to write-host and explicitly use $_ to write those strings:
'foo', 'bar', 'baz' | write-host $_
However, it fails with:
The input object cannot be bound to any parameters for the command either because the command does not take pipeline input or the input and its properties do not match any of the parameters that take pipeline input.
This error message makes no sense to me because I am perfectly able to write
'foo', 'bar', 'baz' | write-host
I would have expected both pipelines to be equivalent. Apparently, they're not. So, what's the difference?
tl;dr
The automatic $_ variable and its alias, $PSItem, only ever have meaningful values inside script blocks ({ ... }), in specific contexts.
The bottom section lists all relevant contexts.
Update: A new conceptual help topic, about_PSItem, is now available, which covers most of what is in the bottom section.
I would have expected both pipelines to be equivalent.
They're not:
'foo', 'bar', 'baz' | write-host
It is the pipeline-based equivalent of the following (equivalent in ultimate effect, not technically):
foreach ($str in 'foo', 'bar', 'baz') { Write-Host -Object $str }
That is, in your command Write-Host receives input from the pipeline that implicitly binds to its -Object parameter for each input object, by virtue of parameter -Object being declared as accepting pipeline input via attribute [Parameter(ValueFromPipeline=$true)]
'foo', 'bar', 'baz' | write-host $_
Before pipeline processing begins, arguments - $_ in your case - are bound to parameters first:
Since $_ isn't preceded by a parameter name, it binds positionally to the - implied - -Object parameter.
Then, when pipeline processing begins, pipeline parameter binding finds no pipeline-binding Write-Host parameter to bind to anymore, given that the only such parameter, -Object has already been bound, namely by an argument $_.
In other words: your command mistakenly tries to bind the -Object parameter twice; unfortunately, the error message doesn't exactly make that clear.
The larger point is that using $_ only ever makes sense inside a script block ({ ... }) that is evaluated for each input object.
Outside that context, $_ (or its alias, $PSItem) typically has no value and shouldn't be used - see the bottom section for an overview of all contexts in which $_ / $PSItem inside a script block is meaningfully supported.
While $_ is most typically used in the script blocks passed to the ForEach-Object and Where-Object cmdlets, there are other useful applications, most typically seen with the Rename-Item cmdlet: a delay-bind script-block argument:
# Example: rename *.txt files to *.dat files using a delay-bind script block:
Get-ChildItem *.txt | Rename-Item -NewName { $_.BaseName + '.dat' } -WhatIf
That is, instead of passing a static new name to Rename-Item, you pass a script block that is evaluated for each input object - with the input object bound to $_, as usual - which enables dynamic behavior.
As explained in the linked answer, however, this technique only works with parameters that are both (a) pipeline-binding and (b) not [object] or [scriptblock] typed; therefore, given that Write-Object's -Object parameter is [object] typed, the technique does not work:
# Try to enclose all inputs in [...] on output.
# !! DOES NOT WORK.
'foo', 'bar', 'baz' | write-host -Object { "[$_]" }
Therefore, a pipeline-based solution requires the use of ForEach-Object in this case:
# -Object is optional
PS> 'foo', 'bar', 'baz' | ForEach-Object { write-host -Object "[$_]" }
[foo]
[bar]
[baz]
Contexts in which $_ (and its alias, $PSItem) is meaningfully defined:
What these contexts have in common is that the $_ / $PSItem reference must be made inside a script block ({ ... }), namely one passed to / used in:
... the ForEach-Object and Where-Object cmdlets; e.g.:
1..3 | ForEach-Object { 1 + $_ } # -> 2, 3, 4
... the intrinsic .ForEach() and intrinsic .Where() methods; e.g.:
(1..3).ForEach({ 1 + $_ }) # -> 2, 3, 4
... a parameter, assuming that parameter allows a script block to act as a delay-bind script-block parameter; e.g.:
# Rename all *.txt files to *.dat files.
Get-ChildItem *.txt | Rename-Item -NewName { $_.BaseName + '.dat' } -WhatIf
... conditionals and associated script blocks inside a switch statement; e.g.:
# -> 'is one or three: one', 'is one or three: three'
switch ('one', 'two', 'three') {
{ $_ -in 'one', 'three' } { 'is one or three: ' + $_ }
}
... simple function and filters; e.g.:
# -> 2, 3
function Add-One { process { 1 + $_ } }; 1..2 | Add-One
# -> 2, 3
filter Add-One { 1 + $_ }; 1..2 | Add-One
... direct subscriptions to an object's event (n/a to the script block that is passed to the -Action parameter of a Register-ObjectEvent call); e.g::Tip of the hat to Santiago Squarzon.
# In a direct event-subscription script block used in the
# context of WinForms; e.g:
$txtBox.Add_KeyPress({
param($sender, $eventArgs)
# The alternative to the explicitly defined parameters above is:
# $this ... implicitly the same as $sender, i.e. the event-originating object
# $_ / $PSItem ... implicitly the same as $eventArgs, i.e. the event-arguments object.
})
... the [ValidateScript()] attribute in parameter declarations; note that for array-valued parameters the script block is called for each element; e.g.,
function Get-Foo {
param(
[ValidateScript({ 0 -eq ($_ % 2) })]
[int[]] $Number
)
"All numbers are even: $Number"
}
PowerShell (Core) only: ... the substitution operand of the -replace operator; e.g.:
# -> 'a10, 'a20'
'a1', 'a2' -replace '\d+', { 10 * [int] $_.Value }
... in the context of <ScriptBlock> elements in formatting files (but not in the context of script block-based ETS members, where $this is used instead).
... in the context of using PowerShell SDK methods such as .InvokeWithContext()
# -> 43
{ 1 + $_ }.InvokeWithContext($null, [psvariable]::new('_', 42), $null)
You can use it as iRon has indicated in the comments. $_ or $PSItem is the current object in the pipeline that is being processed. Typically, you see this with commands that require a processing or script block. You would have to contain your Write-Host command within a similar processing block.
'foo', 'bar', 'baz' | ForEach-Object {write-host $_}
Here is an example using the process block of a function:
function write-stuff {
process { write-host $_ }
}
'foo', 'bar', 'baz' | write-stuff
bar
foo
hi
$_ will never work outside a scriptblock. How about write-output instead:
'hi' | Write-Output -InputObject { $_ + ' there' }
hi there

Default value of parameter is not used in function

I have a very basic PowerShell script:
Param(
[string]$MyWord
)
function myfunc([string] $MyWord) {
Write-Host "$MyWord"
}
myfunc #PSBoundParameters
This is how I execute it:
PS C:\> .\test.ps1 -MyWord 'hello'
hello
All fine. But I want to set a default value if -MyWord isn't specified.
I tried this:
Param(
[string]$MyWord='hi'
)
function myfunc([string] $MyWord) {
Write-Host "$MyWord"
}
myfunc #PSBoundParameters
But than the output of my script was just empty. It was printing nothing when I did not describe my parameter. (it only showed 'hello' if I specified the parameter).
I also tried:
Param(
[string]$MyWord
)
function myfunc([string] $MyWord) {
[string]$MyWord='hi'
Write-Host "$MyWord"
}
myfunc #PSBoundParameters
But than the output was of course always 'hi' and never 'hello'. Even when I executed the script with the parameter -MyWord 'hello'
Can someone explaining what I'm doing wrong?
When I'm not using the function it is working as expected:
Param(
[string]$MyWord='hi'
)
Write-Host $MyWord
Output:
PS C:\> .\test.ps1 -MyWord 'hallo'
hallo
PS C:\> .\test.ps1
hi
Automatic variable $PSBoundParameters, as the name suggests, contains only bound parameters, where bound means that an actual value was supplied by the caller.
Therefore, a parameter default value does not qualify as binding the associated parameter, so $MyWord with its default value of 'hi' does not become part of $PSBoundParameters.
Note: Arguably, a parameter with a default value should also be considered bound (it is bound by its default value, as opposed to by a caller-supplied value). Either way, it would be convenient to have an automatic variable that includes default values too, so as to enable simple and comprehensive passing through of arguments. A suggestion has been submitted to the PowerShell repository as GitHub issue #3285.
Workarounds
The following solutions assume that you want to pass the default value through, and don't want to simply duplicate the default value in function myfunc (as demonstrated in Ansgar Wiecher's helpful answer), because that creates a maintenance burden.
Regarding function syntax: The following two forms are equivalent (in this case), though you may prefer the latter for consistency and readability.[1]
function myfunc([string] $MyWord = 'hi') { ... }
parameter declaration inside (...) after the function name.
function myfunc { param([string] $MyWord = 'hi') ... }
parameter declaration inside a param(...) block inside the function body.
A simple fix would be to add the default value explicitly to $PSBoundParameters:
Param(
[string]$MyWord = 'hi'
)
function myfunc ([string] $MyWord){
Write-Host "$MyWord"
}
# Add the $MyWord default value to PSBoundParameters.
# If $MyWord was actually bound, this is effectively a no-op.
$PSBoundParameters.MyWord = $MyWord
myfunc #PSBoundParameters
To achieve what you want generically, you must use reflection (introspection):
param(
[alias('foop')]
[string]$MyWord = 'hi'
)
function myfunc ([string] $MyWord) {
Write-Host "$MyWord"
}
# Add all unbound parameters that have default values.
foreach ($paramName in $MyInvocation.MyCommand.Parameters.Keys) {
if (-not $PSBoundParameters.ContainsKey($paramName)) {
$defaultVal = Get-Variable -Scope Local $paramName -ValueOnly
# A default value is identified by either being non-$null or
# by being a [switch] parameter that defaults to $true (which is bad practice).
if (-not ($null -eq $defaultVal -or ($defaultVal -is [switch] -and -not $defaultVal))) {
$PSBoundParameters[$paramName] = $defaultVal
}
}
}
myfunc #PSBoundParameters
[1] The param(...) form is required if you need to use the [CmdletBinding()] attribute with non-default values, as well as in scripts (.ps1). See this answer.
A parameter is bound only if you actually pass it a value, meaning that a parameter's default value does not show up in $PSBoundParameters. If you want to pass script parameters into a function, you must replicate the script parameter set in the function parameter set:
Param(
[string]$MyWord = 'hi'
)
function myfunc([string]$MyWord = 'hi') {
Write-Host "$MyWord"
}
myfunc #PSBoundParameters
Maintaining something like this is easier if you define both parameter sets the same way, though, so I'd put the function parameter definition in a Param() block as well:
Param(
[string]$MyWord = 'hi'
)
function myfunc {
Param(
[string]$MyWord = 'hi'
)
Write-Host "$MyWord"
}
If you want to use "Param" enclose it in the function like this:
function myfunc {
Param(
[string]$MyWord='hi'
)
Write-Host "$MyWord"
}
Very simple way is,
function myfunc([string]$MyWord = "hi") {
Write-Output $MyWord
}

Pass an unspecified set of parameters into a function and thru to a cmdlet

Let's say I want to write a helper function that wraps Read-Host. This function will enhance Read-Host by changing the prompt color, calling Read-Host, then changing the color back (simple example for illustrative purposes - not actually trying to solve for this).
Since this is a wrapper around Read-Host, I don't want to repeat the all of the parameters of Read-Host (i.e. Prompt and AsSecureString) in the function header. Is there a way for a function to take an unspecified set of parameters and then pass those parameters directly into a cmdlet call within the function? I'm not sure if Powershell has such a facility.
for example...
function MyFunc( [string] $MyFuncParam1, [int] $MyFuncParam2 , Some Thing Here For Cmdlet Params that I want to pass to Cmdlet )
{
# ...Do some work...
Read-Host Passthru Parameters Here
# ...Do some work...
}
It sounds like you're interested in the 'ValueFromRemainingArguments' parameter attribute. To use it, you'll need to create an advanced function. See the about_Functions_Advanced and about_Functions_Advanced_Parameters help topics for more info.
When you use that attribute, any extra unbound parameters will be assigned to that parameter. I don't think they're usable as-is, though, so I made a little function that will parse them (see below). After parsing them, two variables are returned: one for any unnamed, positional parameters, and one for named parameters. Those two variables can then be splatted to the command you want to run. Here's the helper function that can parse the parameters:
function ParseExtraParameters {
[CmdletBinding()]
param(
[Parameter(ValueFromRemainingArguments=$true)]
$ExtraParameters
)
$ParamHashTable = #{}
$UnnamedParams = #()
$CurrentParamName = $null
$ExtraParameters | ForEach-Object -Process {
if ($_ -match "^-") {
# Parameter names start with '-'
if ($CurrentParamName) {
# Have a param name w/o a value; assume it's a switch
# If a value had been found, $CurrentParamName would have
# been nulled out again
$ParamHashTable.$CurrentParamName = $true
}
$CurrentParamName = $_ -replace "^-|:$"
}
else {
# Parameter value
if ($CurrentParamName) {
$ParamHashTable.$CurrentParamName += $_
$CurrentParamName = $null
}
else {
$UnnamedParams += $_
}
}
} -End {
if ($CurrentParamName) {
$ParamHashTable.$CurrentParamName = $true
}
}
,$UnnamedParams
$ParamHashTable
}
You could use it like this:
PS C:\> ParseExtraParameters -NamedParam1 1,2,3 -switchparam -switchparam2:$false UnnamedParam1
UnnamedParam1
Name Value
---- -----
switchparam True
switchparam2 False
NamedParam1 {1, 2, 3}
Here are two functions that can use the helper function (one is your example):
function MyFunc {
[CmdletBinding()]
param(
[string] $MyFuncParam1,
[int] $MyFuncParam2,
[Parameter(Position=0, ValueFromRemainingArguments=$true)]
$ExtraParameters
)
# ...Do some work...
$UnnamedParams, $NamedParams = ParseExtraParameters #ExtraParameters
Read-Host #UnnamedParams #NamedParams
# ...Do some work...
}
function Invoke-Something {
[CmdletBinding()]
param(
[Parameter(Mandatory=$true, Position=0)]
[string] $CommandName,
[Parameter(ValueFromRemainingArguments=$true)]
$ExtraParameters
)
$UnnamedParameters, $NamedParameters = ParseExtraParameters #ExtraParameters
&$CommandName #UnnamedParameters #NamedParameters
}
After importing all three functions, try these commands:
MyFunc -MyFuncParam1 Param1Here "PromptText" -assecure
Invoke-Something -CommandName Write-Host -Fore Green "Some text" -Back Red
One word: splatting.
Few more words: you can use combination of $PSBoundParameters and splatting to pass parameters from external command, to internal command (assuming names match). You would need to remove any parameter that you don't want to use though from $PSBoundParameters first:
$PSBoundParameters.Remove('MyFuncParam1')
$PSBoundParameters.Remove('MyFuncParam2')
Read-Host #PSBoundParameters
EDIT
Sample function body:
function Read-Data {
param (
[string]$First,
[string]$Second,
[string]$Prompt,
[switch]$AsSecureString
)
$PSBoundParameters.Remove('First') | Out-Null
$PSBoundParameters.Remove('Second') | Out-Null
$Result = Read-Host #PSBoundParameters
"First: $First Second: $Second Result: $Result"
}
Read-Data -First Test -Prompt This-is-my-prompt-for-read-host

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