Summary: Windows PowerShell MVP, Dave Wyatt, talks about accessing caller preference variables.
Microsoft Scripting Guy, Ed Wilson, is here. Today I would like to welcome a new guest blogger and new Windows PowerShell MVP, Dave Wyatt.
Dave has worked in the IT field for about 14 years as a software developer and a systems administrator/engineer.
He started by writing software in C++ before switching career paths, but he has been writing batch, VBScript,
and Windows PowerShell scripts ever since, to scratch his “developer itch.”
Lately (when he’s not busy working, and being a husband and daddy), he writes blog posts about Windows PowerShell
on http://PowerShell.org. He wrote the Big Book of PowerShell Error Handling free eBook, and he was recently among
the first group of people to receive the “PowerShell Hero” award. Check out these resources:
- Dave's blogs on PowerShell.org
- Script Center Repository documents
- Twitter: @MSH_Dave
Note Today's blog post talks about an edge case. Most Windows PowerShell users will never run into this issue. The workaround suggested by Dave is cool, and it works. But please do not think this is a major issue and this workaround must be used. I debated back and forth, and even looped in Lee Holmes before deciding to publish Dave’s post. The reason is that I don’t want to confuse or mislead readers into thinking that this is something they MUST do. I also surveyed a group of a half dozen Microsoft PFEs. Not one of them had even run into this issue. In the end, I decided to publish Dave’s post because IF you run into this issue, he offers a clever and a workable solution. There is a really good chance you will never need the solution, but if you do, here it is…
Hello, everyone! I've been reading this blog and using it as a reference for years, and am honored to have been invited to write a guest post. Hopefully, you'll like it, and it will be the first of many! In this post, we’ll explore an issue that I discovered while writing and testing my first Windows PowerShell script modules, and how I managed to work around it.
While writing and testing my first script modules, I observed that if I set a preference variable in the calling script (for example, $ErrorActionPreference), the functions in my script modules weren’t picking up that change. They say a picture is worth a thousand words, so let’s see this issue in action:
A quick note about the ISE: When you press the F5 button to run script in the ISE (or when you select Run/Continue from the Debug menu), it appears to have the same effect as dot-sourcing the script. That is, the script is executed in the Global scope, and that will interfere with the test results. To generate the screenshots in this post, I had to manually type “.\test.ps1” in the console window to get it to execute the way it normally would in a PowerShell.exe console.
As you can see in the screenshot, the Get-Item cmdlet and the Test-Function function are both behaving properly after the test.ps1 script sets $ErrorActionPreference to SilentlyContinue. The Test-ModuleFunction function, on the other hand, reported that $ErrorActionPreference was set to Continue instead of SilentlyContinue, and still displayed its error message.
This annoyed the heck out of me! My understanding was that advanced functions should behave like cmdlets, from the caller’s point of view. The caller expects to be able to set a variable like $ErrorActionPreference, and have the commands it calls behave accordingly. With that goal in mind, I set out to figure out how I could make my functions work the way I thought they should.
I learned that script modules and the calling scripts inherit from the Global variable scope (which applies to the entire Windows PowerShell session), but that’s the only thing they have in common. Here’s a visual representation of the variable scopes. Those linked by solid lines (and arranged vertically) represent scopes that inherit variables from their parent scopes. I’ve given each scope a name in quotation marks, which will be used to refer to them later for clarity.
This means that the module won’t automatically inherit preference variables from a calling script unless the caller makes sure to set them in the Global scope, which affects the entire Windows PowerShell session, even after the script is complete.
I didn’t care for that solution. My scripts should not be polluting the Global scope. Instead of accepting this answer, I started looking into how compiled cmdlets work. When a cmdlet needs to look up the value of a Windows PowerShell variable for some reason (such as to check the value of ErrorActionPreference), it does so via certain .NET objects and methods. As luck would have it, these objects and methods also happen to be accessible via the automatic $PSCmdlet variable in a Windows PowerShell advanced function. The simplest of these is $PSCmdlet.GetVariableValue(‘VariableName’). When you call this method, the variable is looked up in caller’s scope (or its parent scopes), instead of the ModuleFunction scope.
As you can see in the next screenshot, with a few extra lines of code using this .NET method, ModuleFunction was able to look up caller’s value for ErrorActionPreference and assign it locally, and the error no longer shows up in the console:
Now I was on the right track. The advanced function is behaving just like compiled cmdlets, regardless of whether it’s in a script module. Note that before I called $PSCmdlet.GetVariableValue(), I had to make sure that caller hadn’t specified -ErrorAction on the command line when calling ModuleFunction. This was necessary to behave the same way as a compiled cmdlet. If the command-line parameter is used, the caller’s preference variable should be ignored.
I used this technique for a little while, but I found myself having to copy and paste large blocks of code like that into every exported function of a script module, if I wanted the functions to behave properly according to the preference variables for Windows PowerShell (of which there are about 30, by the way… check out the about_Preference_Variables Help file sometime). What I really needed to do was get this code into its own function, which could be called with a single line of code.
That presented a whole new challenge, though. The original problem has to do with variable scopes, and now I was trying to introduce yet another function's scope into the mix, and possibly another script module. The new function (which I’ll refer to as ImportFunction) would have to not only read variables from the caller’s scope, it would also have to be able to set variables in the ModuleFunction scope—but only if the corresponding common parameter (for example, -ErrorAction) was not used when calling ModuleFunction. ImportFunction would need to work regardless of whether ImportFunction and ModuleFunction were in the same script module.
It took a lot of trial and error to get a function working according to those requirements, and I won’t bore you with the details of the failed attempts. What I eventually discovered is that I could make this work if ModuleFunction passed two objects to ImportFunction: $PSCmdlet and $ExecutionContext.SessionState.
$PSCmdlet is used to look up variable values in the caller’s scope, similar to what we did in the last example. $ExecutionContext.SessionState can be used to set variables in the ModuleFunction scope. However, there’s a catch: This technique only works if ImportFunction and ModuleFunction are in separate script modules.
If they’re in the same module, ImportFunction and ModuleFunction share an $ExecutionContext.SessionState object. That meant that if I set the variables by using the SessionState object, I wound up setting them in the ImportFunction scope instead of the ModuleFunction scope.
Instead, I had to set the variables by using the Set-Variable cmdlet with a scope of “1”, if the functions are in the same module. (See the about_Scopes Help file for a description of numbered scopes. “1” refers to the immediate parent scope.)
Here’s a snippet of the function’s code, which shows how these tasks were accomplished:
function Get-CallerPreference
{
[CmdletBinding()]
param (
[Parameter(Mandatory = $true)]
[ValidateScript({ $_.GetType().FullName -eq 'System.Management.Automation.PSScriptCmdlet' })]
$Cmdlet,
[Parameter(Mandatory = $true)]
[System.Management.Automation.SessionState]
$SessionState
)
$vars = @{
'ErrorView' = $null
'ErrorActionPreference' = 'ErrorAction'
}
foreach ($entry in $vars.GetEnumerator())
{
if ([string]::IsNullOrEmpty($entry.Value) -or -not $Cmdlet.MyInvocation.BoundParameters.ContainsKey($entry.Value))
{
$variable = $Cmdlet.SessionState.PSVariable.Get($entry.Key)
if ($null -ne $variable)
{
if ($SessionState -eq $ExecutionContext.SessionState)
{
Set-Variable -Scope 1 -Name $variable.Name -Value $variable.Value -Force -Confirm:$false -WhatIf:$false
}
else
{
$SessionState.PSVariable.Set($variable.Name, $variable.Value)
}
}
}
}
}
The complete Get-CallerPreference function can be downloaded from the Script Center Repository: Import Preference variables from the caller of a Script Module function.
The main differences between the complete function and the snippet posted here are that the default $vars hash table contains many more entries, and the function also includes a –Name parameter, which allows you to specify variable names other than the Windows PowerShell default preference variables, if desired.
With this function, it only takes one line of code in any script module function to import its caller’s preference variables, as shown here:
There you have it! If you’ve come across this behavior while writing your own Windows PowerShell script modules, and if it’s annoyed you as much as it annoyed me, all you have to do is download the Get-CallerPreference function, add it to your module, and add one line of code to call Get-CallerPreference in each of your other exported functions.
If you have any questions or comments about the function, please feel free to leave them on this blog post or on the TechNet Gallery. Or contact me directly via Twitter (http://twitter.com/MSH_Dave). Enjoy!
~Dave
Thank you for sharing, Dave! This is a very interesting solution to a very unique issue. Awesome job!
I invite you to follow me on Twitter and Facebook. If you have any questions, send email to me at scripter@microsoft.com, or post your questions on the Official Scripting Guys Forum. See you tomorrow. Until then, peace.
Ed Wilson, Microsoft Scripting Guy