How to achieve #args splatting in an advanced function in Powershell? - function

Consider the following simple function:
function Write-HostIfNotVerbose()
{
if ($VerbosePreference -eq 'SilentlyContinue')
{
Write-Host #args
}
}
And it works fine:
Now I want to make it an advanced function, because I want it to inherit the verbosity preference:
function Write-HostIfNotVerbose([Parameter(ValueFromRemainingArguments)]$MyArgs)
{
if ($VerbosePreference -eq 'SilentlyContinue')
{
Write-Host #MyArgs
}
}
But it does not work:
And what drives me nuts is that I am unable to identify how $args in the first example is different from $args in the second.
I know that the native #args splatting does not work for advanced functions by default - https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_splatting?view=powershell-7.2#notes
But I hoped it could be simulated, yet it does not work either. My question is - what is wrong with the way I am trying to simulate it and whether it is possible to fix my code without surfacing all the Write-Host parameters at Write-HostIfNotVerbose

Santiago Squarzon's helpful answer contains some excellent sleuthing that reveals the hidden magic behind #args, i.e. splatting using the automatic $args variable, which is available in simple (non-advanced) functions only.
The solution in Santiago's answer isn't just complex, it also isn't fully robust, as it wouldn't be able to distinguish -ForegroundColor (a parameter name) from '-ForegroundColor' a parameter value that happens to look like a parameter name, but is distinguished from it by quoting.
As an aside: even the built-in #args magic has a limitation: it doesn't correctly pass a [switch] parameter specified with an explicit value through, such as
-NoNewLine:$false[1]
A robust solution requires splatting via the automatic $PSBoundParameters variable, which in turn requires that the wrapping function itself also declare all potential pass-through parameters.
Such a wrapping function is called a proxy function, and the PowerShell SDK facilitates scaffolding such functions via the PowerShell SDK, as explained in this answer.
In your case, you'd have to define your function as follows:
function Write-HostIfNotVerbose {
[CmdletBinding()]
param(
[Parameter(Position = 0, ValueFromPipeline, ValueFromRemainingArguments)]
[Alias('Msg', 'Message')]
$Object,
[switch] $NoNewline,
$Separator,
[System.ConsoleColor] $ForegroundColor,
[System.ConsoleColor] $BackgroundColor
)
begin {
$scriptCmd =
if ($VerbosePreference -eq 'SilentlyContinue') { { Write-Host #PSBoundParameters } }
else { { Out-Null } }
$steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
$steppablePipeline.Begin($PSCmdlet)
}
process {
$steppablePipeline.Process($_)
}
end {
$steppablePipeline.End()
}
}
[1] Such an argument is invariably passed through as two arguments, namely as parameter name -NoNewLine by itself, followed by a separate argument, $false. The problem is that at the time the original arguments are parsed into $args, it isn't yet known what formally declared parameters they will bind to. The NoteProperty tagging applied to $args for marking elements as parameter names doesn't preserve the information as to whether the subsequent argument was separated from the parameter name with :, which for a [switch] parameter is necessary to identify that argument as belonging to the switch. In the absence of this information, two separate arguments are always passed during splatting.

This is too obscure for me to explain, but for the sake of answering what PowerShell could be doing with $args you can test this:
function Write-HostIfNotVerbose {
param(
[parameter(ValueFromRemainingArguments)]
[object[]]$MagicArgs
)
$params = #{
NotePropertyName = '<CommandParameterName>'
PassThru = $true
InputObject = ''
}
$z = foreach($i in $MagicArgs) {
if($i.StartsWith('-')) {
$params.NotePropertyValue = $i
Add-Member #params
continue
}
$i
}
if ($VerbosePreference -eq 'SilentlyContinue') {
Write-Host #z
}
}
Write-HostIfNotVerbose -ForegroundColor Green Hello world! -BackgroundColor Yellow
A way of seeing what $args is doing automatically for us could be to serialize the variable:
function Test-Args {
[System.Management.Automation.PSSerializer]::Serialize($args)
}
Test-Args -Argument1 Hello -Argument2 World
Above would give us the serialized representation of $args where we would observe the following:
<LST>
<Obj RefId="1">
<S>-Argument1</S>
<MS>
<S N="<CommandParameterName>">Argument1</S>
</MS>
</Obj>
<S>Hello</S>
<Obj RefId="2">
<S>-Argument2</S>
<MS>
<S N="<CommandParameterName>">Argument2</S>
</MS>
</Obj>
<S>World</S>
</LST>

Related

Cannot use variable in a Where-Object in a function

I am trying to have a function that can count jobs based on the LatestStatus value that I would pass a parameter. So far what I got:
Function JobCountStatus {
Write-Output (Get-VBRJob | ?{$_.Info.LatestStatus -eq $args} | Measure-Object).Count
}
The issue is that as I've read somewhere there will be a subshell(?) executing the where so the argument is not passed.
If I replace the $args with a specific string like "Failed" it will work.
Is there a way to overcome this? I do not want to write separate functions for all possible values.
I would appreciate any comments - Thanks
Well you can just name the value when you run the function as $args is an Automatic Variable
JobCountStatus "Failed"
You can use an advanced function with a parameter, named or not:
function JobCountStatus {
[CmdletBinding()]
Param (
[Parameter(Mandatory = $true, Position = 0)]
[System.String]
$Status
)
Process {
(Get-VBRJob | Where-Object { $_.Info.LatestStatus -eq $Status } | Measure-Object).Count
}
}
And call it like so:
JobCountStatus -Status "Failed"
# OR
JobCountStatus "Failed"
The latter having the same end result as just using $args. The only possible advantage to specifying your own parameter here is you could define a ValidateSet of statuses or an Enum of Status values so you can tab through them. An example of the former would be like so:
function JobCountStatus {
[CmdletBinding()]
Param (
[Parameter(Mandatory = $true, Position = 0)]
[ValidateSet("Failed", "Running", "Successful", "Unknown")]
[System.String]
$Status
)
Process {
(Get-VBRJob | Where-Object { $_.Info.LatestStatus -eq $Status } | Measure-Object).Count
}
}
$args is an array, not a single value. In any case, a ScriptBlock {} is an unnamed function and $args has it's own meaning within it, so you can't use it without some modification in something like Where-Object. You would have to store the elements of $args as another variable or multiple variables to reference within a child ScriptBlock. Not a huge change for this function, but for one where more parameters are expected this can result in a lot of unnecessary code which can be difficult to maintain.
I prefer to recommend defining named parameters in most cases, and this would be a simpler change than making the parent $args work in a child ScriptBlock:
Function JobCountStatus {
Param(
[string]$Status
)
( Get-VBRJob | Where-Object { $_.Info.LatestStatus -eq $Status } ).Count
}
I've also made a few more changes in this function, I'll explain below:
Use Param() to strongly define parameters by name. You could use the simpler syntax of function JobCountStatus([string]$Status) {} but for this case it's really a matter of preference for which technique to use. Using Param() is something I recommend as a matter of convention, but you'll need to use either technique to get a named parameter.
I replaced the $args reference with $Status.
Your use of Measure-Object is extraneous and so I've removed it. Where-Object returns a collection which already has the Count property.
You can use ? if you want but it's considered best practice to omit aliases and use full cmdlet names in scripts and modules, so I've replaced ? with Where-Object.
Note that you can invoke the function/cmdlet (the difference is minimal for PowerShell-defined cmdlets) with or without the parameter, as when you don't define a positional order, the order is automatically determined in the order of declaration:
# Either will work
JobCountStatus -Status Running
JobCountStatus Running
Here is some more documentation you may find useful:
About Functions
About Advanced Functions
#Ash's answer gives some more advanced examples of what you can do with param() which are mentioned in the the Advanced Functions link above. You cannot use advanced parameter attributes with the simple function syntax I mentioned in the first bullet point.

Powershell hashtable in function not accessible

I created a function to import HR records so they're easily accessible by indexing the employeeID.
function hrDataImport {
$HRFile=Import-Csv $rawHRFile #$rawHRFile path previously defined
Write-Host "Creating HR hash table..."
$hrData = #{}
$HRFile | ForEach-Object { $hrData[$_.employeeID] = $_ }
Write-Host "Done. " $hrData.Count
}
I'd like to use that $hrData variable outside the function. I can't reference $hrData['employeeID'] as I've read that variables in functions don't exist outside the function. One thing I tried is creating another function to pass an employeeID into the $hrData hashtable:
function showHRData {
param ([string]$hrUser)
$hrData[$hrUser]
}
I put the functions in a module and imported successfully. I'm able to execute importHRData fine but when I try showHRData -hrUser $employeeID I get "Cannot index into a null array". Seems like the function does not see the hashtable variable from the previous function. What am I doing wrong or what would you suggest so I can access that $hrData hashtable across various scripts?
You might create a Hashtable within your function using a $global (or $script) scope like this:
function hrDataImport {
$HRFile = Import-Csv $rawHRFile #$rawHRFile path previously defined
Write-Host "Creating HR hash table..."
if (!$hrData) { $Global:hrData = #{} }
$HRFile | ForEach-Object { $Global:hrData[$_.employeeID] = $_ }
Write-Host "Done. " $hrData.Count
}
But in general it is advised to avoid the global (or script) scope mainly because it might conflict with variables in the current scope.
To minimize this possibility, I would consider to change the responsibility of your function to not only loading the information but also to receiving the concerned employeeID, e.g.:
function Get-EmployeeData ($EmployeeID) {
if (!$StaticEmployeeData) {
Write-Host "Creating HR hash table..."
$HRFile = Import-Csv $rawHRFile #$rawHRFile path previously defined
$Global:StaticEmployeeData = #{}
$HRFile | ForEach-Object { $Global:StaticEmployeeData[$_.employeeID] = $_ }
Write-Host "Done. " $StaticEmployeeData.Count
}
$Global:StaticEmployeeData[$employeeID]
}
function showHRData {
param ([string]$hrUser)
Get-EmployeeData $hrUser
}
In that case you might choose for a more specific global variable name (e.g. $StaticEmployeeData, knowing that PowerShell doesn't support something like a static variable) and as an extra bonus the data is only loaded the first time you really need it (lazy evaluation).

Repeatable Parameters in Powershell Function (Preferably Linked Parameter Sets)

I am wondering if it is possible (and if so how) to create repeatable (and hopefully linked) parameters in a PowerShell function. This is how am looking for this to work:
function foo()
{
[CmdletBinding()]
Params(
[Parameter(Mandatory=$true,ParameterSetName="Default")]
[Parameter(Mandatory=$true,ParameterSetName="Set1")]
[Parameter(Mandatory=$true,ParameterSetName="Set2")]
[string]$SomeParam1,
[Parameter(Mandatory=$true,ParameterSetName="Set1")]
[Parameter(Mandatory=$true,ParameterSetName="Set2")]
*some magic here, likely, to make this repeatable*
[string]$SomeRepeatableParam,
[Parameter(Mandatory=$true,ParameterSetName="Set1")]
[string]$SomeLinkedParam1,
[Parameter(Mandatory=$true,ParameterSetName="Set2")]
[string]$SomeLinkedParam2
)
Begin
{
*some code here*
}
Process
{
foreach ($val in $SomeRepeateableParam)
{
*some code here using param and its linked param*
}
}
End
{
*some code here*
}
}
And then call this function like so:
foo -SomeParam "MyParam" -SomeRepeatableParam "MyProperty1" -SomeLinkedParam1 "Tall" -SomeRepeatableParam "MyProperty2" -SomeLinkedParam2 "Wide"
and so on, being able to use the repeatable parameter as many times in a single call as I feel like it.
Can this be done? And if so how?
Thanks for your time.
EDIT: For clarity, I don't mean an array parameter, but a repeatable parameter in which the linked parameter sets can be matched to each instance of the repeatable parameter.
Since PowerShell supports arrays as parameter values, there is generally no need to repeat a parameter.
There is no syntactic way to enforce the pairing (linking) of parameter values the way you intend, with repeating instances of the same parameter name, because parameter names must be unique (and even they didn't have to be unique, that alone wouldn't enforce the desired pairing).
You can, however, use parallel array parameters, and enforce their symmetry inside the function, e.g.:
function foo
{
[CmdletBinding()]
Param(
[string] $SomeParam1,
[string[]] $SomeRepeatableParam,
[string[]] $SomeLinkedParam
)
if ($SomeRepeatableParam.Count -ne $SomeLinkedParam.Count) {
Throw "Please specify paired values for -SomeRepeatableParam and -SomeLinkedParam"
}
for ($i = 0; $i -lt $SomeRepeatableParam.Count; ++$i) {
$SomeRepeatableParam[$i] + ': ' + $SomeLinkedParam[$i]
}
}
You would then call it as follows (note the , to separate the array elements):
foo -SomeParam1 "MyParam" `
-SomeRepeatableParam "MyProperty1", "MyProperty2" `
-SomeLinkedParam "Tall", "Wide"

How do I package powershell advanced function args into a hashtable

I have a function
function Add-PromptSection() {
[CmdletBinding()]
Param(
[ConsoleColor]$Fore,
[ConsoleColor]$Back,
[Switch]$Newline,
[Scriptblock]$Condition,
[String]$String
)
$args = <something>
}
What I want to do is to collect the function arguments into a hashtable
#{ Fore=$Fore; Back=$Back; Newline=$Newline;
Condition=$Condition; String=$String }
However, if the user omitted a particular argument when calling the function, I want that key to be omitted from the hashtable. The idea is that after a bit of preprocessing, I will call Write-Host with the hashtable, as Write-Host #args. So I don't want something like $args["Back"] = $null in there.
Obviously I can do this by explicitly checking each argument (actually, can I? How do I tell if the user didn't supply $Fore? Checking for $null will work, I guess, but it's not quite the same behaviour as Write-Host) but is there a more compact approach?
It seems like what you're wanting (at least the hash table) is already being done for you by $PSBoundParameters.
if I understand your question, you are looking for default values for your arguments. Please correct me if I am wrong.
function Add-PromptSection() {
[CmdletBinding()]
Param(
[ConsoleColor]$Fore,
[ConsoleColor]$Back="Red",
[Switch]$Newline,
[Scriptblock]$Condition,
[String]$String
)
Write-Host $args
}
Now, the user can safely ignore providing $back in hashtable.

How do I pass a collection of object types to a function as a parameter in PowerShell?

Here is my code:
function Get-OSInfo {
param([string]$Computer)
$OS = gwmi -class Win32_OperatingSystem -computer $Computer
$OS | Add-Member –MemberType NoteProperty –Name OSType –Value ""
$OS.OSType = Get-OSType -Input $OS
write $OS
}
function Get-OSType {
param([?]$Input)
if ($Input.ProductType -eq 1) {
write "Client OS"
}
}
$blah = Get-OSInfo -Computer mypc
$blah | fl *
I realize that this could be done with a single function (or in the body of the script itself), but I have simplified the functions to highlight the trouble I'm having. What I want to do is pass the gwmi dataset from the Get-OSInfo function as a parameter variable in the Get-OSType so I can reference all of its properties in the second function without passing them individually from the first. Clear as mud?
I have tried multiple parameter accelerator types, [ref], [array], [object[]], etc., but I haven't found anything that works as a parameter. The only thing that has proven to work is to change the second function to use args[0] for accepting input, but that is not as clean as using parameters, and since it works, I can't help but think there is a parameter that should work as well.
Avoid using $input as that has special meaning in functions (representing pipeline input). Just rename the parameter to something like $OS.
The docs (man about_automatic_variables) on $input say:
Contains an enumerator that enumerates all input that is
passed to a function. The $input variable is available only to
functions and script blocks (which are unnamed functions). In the
Process block of a function, the $input variable enumerates the
object that is currently in the pipeline. When the Process block
completes, there are no objects left in the pipeline, so the $input
variable enumerates an empty collection. If the function does not
have a Process block, then in the End block, the $input variable
enumerates the collection of all input to the function.