I guess this was answered a thousand times, but for love of all I can't find good (matching) answer to my problem.
I have a large PS script with a good dozen global variables that are used in various functions. For variables like $homedir I did not bother to include them in invocation of each function, since virtually all of them need to use it.
But now I need to write another script, and reuse ~80% of functions. Obviously I don't want to just copy&paste, since maintenance would be nightmare, so I told myself "let's finally learn to write PS modules" - basically cutting functions from the script and pasting them into module.
So far so good, but almost immediately I discovered that variables from the script are not passed to the module. I am not surprised by this, I just don't know what is the best practice to refactor my code (provided I don't really want to create functions with 10+ variables as input).
For now, I started adding necessary variables to each function, but the effect is that while before "working directory" variable was a given, now it has to be declared for each function. Hardly nice clean code there.
Is there a way to "init" a module, populating it with global variables?
EDIT:
Let's say I have a following code within a single script:
Function New-WorkDir {
if (Test-Path "$workDirectory") {
$null = Remove-Item -Recurse -Force $workDirectory
}
$null = New-Item -ItemType "directory" -Path "$workDirectory"
}
Function Set-Stage {
$null = New-Item -ItemType "file" -Force -Value $stage -Path "$workDirectory" -Name "ExecutionStage.txt"
}
$workDirectory = "C:\Temp"
New-WorkDir
$stage = "1"
Set-Stage
Now, I want to split the function be in a separate module. In order for this to work, I need to add function parameters explicitly, like so:
Function New-WorkDir {
Param(
[Parameter(Mandatory = $True, Position = 0)] [String] $WorkDirectory
)
if (Test-Path "$WorkDirectory") {
$null = Remove-Item -Recurse -Force $WorkDirectory
}
$null = New-Item -ItemType "directory" -Path "$WorkDirectory"
}
Function Set-Stage {
Param(
[Parameter(Mandatory = $True, Position = 0)] [String] $Stage,
[Parameter(Mandatory = $True, Position = 1)] [String] $WorkDirectory
)
$null = New-Item -ItemType "file" -Force -Value $Stage -Path "$WorkDirectory" -Name "ExecutionStage.txt"
}
And the main script becomes:
Import-Module new-module.psm1
$workDirectory = "C:\Temp"
New-WorkDir -WorkDirectory $workDirectory
$stage = "1"
Set-Stage -WorkDirectory $workDirectory -Stage $stage
So far, so good. My problem is that since virtually every function uses "$workDirectory", now I need to add an additional parameter to each of those functions, and what's worse - I need to add it everywhere in the code, severely impacting readability.
I was hoping that maybe there's some mechanism to "init" internal module variable, something like (pseudocode):
Import-Module new-module.psm1
Set-Variables -module new-module -WorkDirectory $workDirectory
$workDirectory = "C:\Temp"
New-WorkDir
$stage = "1"
Set-Stage -Stage $stage
Help, please?
While modules have state and you could set module variables through module functions that assign to $script:YourVariableName, I wouldn't recommend doing so. Although they are scoped to the module, module variables still smell like an anti-pattern similar to global variables. Having functions depend on state outside of the function makes maintenance and testing much harder. I recommend to use module variables only for constants.
A better pattern is to pass everything to the module functions via parameters. If it turns out that your functions have many common parameters, you could pass these via a single object parameter.
Say you have:
Function MyModuleFun1( $commonParam1, $commonParam2, $foo ) {
Write-Output $commonParam1 $commonParam2 $foo
}
Function MyModuleFun2( $commonParam1, $commonParam2, $bar ) {
Write-Output $commonParam1 $commonParam2 $bar
}
This could be refactored to...
Function MyModuleFun1( $commonParams, $foo ) {
Write-Output $commonParams.param1 $commonParams.param2 $foo
}
Function MyModuleFun2( $commonParams, $bar ) {
Write-Output $commonParams.param1 $commonParams.param2 $bar
}
... and called like this:
$common = [PSCustomObject]#{ param1 = 42; param2 = 21 }
MyModuleFun1 -commonParams $common -foo theFoo
MyModuleFun2 -commonParams $common -bar theBar
In this example the common parameter values are the same for all function calls, so we could use $PSDefaultParameterValues to pass them implicitly:
$PSDefaultParameterValues = #{
'MyModule*:commonParams' = [PSCustomObject]#{ param1 = 42; param2 = 21 }
}
MyModuleFun1 -foo theFoo
MyModuleFun2 -bar theBar
It is advisable to use a common prefix for all your module functions, to make sure that your $PSDefaultParameterValues don't leak into other functions. In my example all module functions start with prefix 'MyModule', so I could write MyModule*:commonParams to pass the common parameter values only to functions that start with 'MyModule' prefix.
For added type safety you could create a class for the common parameters within your module...
class MyModuleCommonParams {
[int] $param1
[String] $param2 = 'DefaultValue'
}
... and change your function signatures like this:
Function MyModuleFun1( [MyModuleCommonParams] $commonParams, $foo )
The function calls can stay the same, but now the function will check that only variables defined in the class, that have correct1 type, are passed:
$common = [PSCustomObject]#{ param1 = 42; xyz = 21 }
MyModuleFun1 -commonParams $common -foo theFoo
PowerShell will report an error, because the member xyz is not defined in class MyModuleCommonParams.
1 Actually it is sufficient that the argument type is convertible to the class member type. You could pass the string '42' to $param1, because it will be automatically converted to int.
Related
I have the following piece of code:
$x = 'xyz'
& {
$y = 'abc'
foo
}
The foo function is defined in the foo.psm1 module which is imported before the script block is started.
Inside the foo function, I call Get-Variable which shows me x but it doesn't show y. I tried playing with the -Scope parameter: Local, Script, Global, 0 - which is the local scope from what I understood from the docs, 1 - which is the parent scope.
How could I get the y variable inside the foo function?
I'm not looking for a solution such as passing it as an argument. I want something as Get-Variable but sadly it doesn't see it for some reason.
UP
Based on the comments received, probably more context is needed.
Say that foo receives a ScriptBlock which is using the $using: syntax.
$x = 'xyz'
& {
$y = 'abc'
foo -ScriptBlock {
Write-Host $using:x
Write-Host $using:y
}
}
I'm 'mining' these variables as follows:
$usingAsts = $ScriptBlock.Ast.FindAll( { param($ast) $ast -is [System.Management.Automation.Language.UsingExpressionAst] }, $true) | ForEach-Object { $_ -as [System.Management.Automation.Language.UsingExpressionAst] }
foreach ($usingAst in $usingAsts) {
$varAst = $usingAst.SubExpression -as [System.Management.Automation.Language.VariableExpressionAst]
$var = Get-Variable -Name $varAst.VariablePath.UserPath -ErrorAction SilentlyContinue
}
This is how I'm using Get-Variable and in the case presented above, y cannot be found.
Modules run in their own scope domain (aka session state), which means they generally do not see the caller's variables - unless (a module-external) caller runs directly in the global scope.
For an overview of scopes in PowerShell, see the bottom section of this answer.
However, assuming that you define the function in your module as an advanced one, there is a way to access the caller's state, namely via the automatic $PSCmdlet variable.
Here's a simplified example, using a dynamic module created via the New-Module cmdlet:
# Create a dynamic module that defines function 'foo'
$null = New-Module {
function foo {
# Make the function and advanced (cmdlet-like) one, via
# [CmdletBinding()].
[CmdletBinding()] param()
# Access the value of variable $bar in the
# (module-external) caller's scope.
# To get the variable *object*, use:
# $PSCmdlet.SessionState.PSVariable.Get('bar')
$PSCmdlet.GetVariableValue('bar')
}
}
& {
$bar = 'abc'
foo
}
The above outputs verbatim abc, as desired.
I have several Powershell scripts which I want to chain together. Each script has some named parameters, and some of these parameters can be shared across scripts.
a.ps1
param(
[String]$foo="",
[String]$bar=""
)
& "./b.ps1"
b.ps1
param(
[String]$baz=""
)
& "./c.ps1"
c.ps1
param(
[String]$thing="",
[String]$stuff="",
[String]$baz="",
[String]$foo=""
)
I want to call the first script in this stack with a single list of arguments
powershell ./a.ps1 -foo myfoo -bar mybar -baz mybaz -thing mything -stuff mystuff
and have each script take what it needs from the parameter list, and then pass the entire list on to the next script, all the way down the chain. The number and order of the parameters in each script can change over time, and this shouldn't have to require changing code in the other scripts. Is this possible? I've tried the usual splatting stuff like #args, and that doesn't seem to work (received parameters are all empty). I can combined bound and unbound args with
$allArgs = $PsBoundParameters.Values + $args
but this arranges all parameters in a fixed sequence that requires each script in the chain to follow the parameter list of its caller.
Edit : The purpose of this exercise to completely obscure the logic within the chain from the point where we call it. I simply provide a list of all parameters required by the entire stack, and each unit script can throw an error if a parameter is invalid.
Create a property bag and pass that.
function a ([PSCustomObject] $obj)
{
"foo: $($obj.foo)"
"bar: $($obj.bar)"
b #PSBoundParameters
}
function b ([PSCustomObject] $obj)
{
"baz: $($obj.baz)"
c #PSBoundParameters
}
function c ([PSCustomObject] $obj)
{
"thing: $($obj.thing)"
"stuff: $($obj.stuff)"
"baz: $($obj.baz)"
"foo: $($obj.foo)"
}
$obj = [PSCustomObject]#{
foo = $null
bar = $null
baz = $null
thing = $null
stuff = $null
}
$obj.foo = "1"
$obj.bar = "2"
$obj.baz = "3"
$obj.thing = "4"
$obj.stuff = "5"
a $obj
# output
foo: 1
bar: 2
baz: 3
thing: 4
stuff: 5
baz: 3
foo: 1
However, I don't recommend doing this because it is shortsighted
$obj = [PSCustomObject]#{
foo = $null
bar = $null
baz = $null
thing = $null
stuff = $null
foov2 = $null
barv2 = $null
bazv2 = $null
thingv2 = $null
stuffv2 = $null
foov3 = $null
barv3 = $null
bazv3 = $null
thingv3 = $null
stuffv3 = $null
stuffv3 = $null
}
and dangerous.
function executeCommand ([PSCustomObject] $obj) { "$($obj.myCommand)" }
$obj = [PSCustomObject]#{
myCommand = "harmless"
}
# lab
$obj.myCommand = "format drive"
executeCommand $obj
# production
executeCommand $obj
Instead, explicitly define and call functions. You might choose to save and reuse variables in scripts. You can dot source functions or create a script-based module.
# UtilityFunctions.ps1
function executeCommand ([string] $command, [string] $hostname) { "$command $hostname" }
# lab.ps1
. C:\scripts\UtilityFunctions.ps1
$hostname = "lab"
$command = "format drive"
executeCommand $command $hostname
# production.ps1
. C:\scripts\UtilityFunctions.ps1
$hostname = "production"
$command = "list free space"
executeCommand $command $hostname
Script scope and dot sourcing
How to Write a PowerShell Script Module - PowerShell | Microsoft Docs
Also, your parameters have a default value of an empty string which probably is not your intent.
param(
[String]$foo="",
[String]$bar=""
)
param(
[String]$foo,
[String]$bar
)
default values
Is there a way to check if a command-line parameter was specified for a PowerShell script from a module (.psm1 file)? I do not need the value, just have to know if the parameter was specified. The $PSBoundParameters.ContainsKey method does not seem to work.
TestParam.psm1:
function Test-ScriptParameter {
[CmdletBinding()]
param ()
# This does not work (always returns false):
return $PSBoundParameters.ContainsKey('MyParam')
}
Export-ModuleMember -Function *
TestParam.ps1:
[CmdletBinding()]
param (
$MyParam= "Default"
)
$path = Join-Path (Split-Path -Path $PSCommandPath -Parent) 'TestParam.psm1'
Import-Module $path -ErrorAction Stop -Force
Test-ScriptParameter
This must return false:
PS>.\TestParam.ps1
This must return true:
PS>.\TestParam.psq -MyParam ""
This must return true:
PS>.\TestParam.ps1 -MyParam "Runtime"
It cannot be done like you are thinking about it. The PSBoundParameters variable is native to the cmdlet's execution and as such depends on the param block of the cmdlet's definition. So in your case, the Test-ScriptParameter is checking if it was invoked with the parameter MyParam but since it doesn't specify it, then it will be always false.
To achieve what I believe you want, you need to create a function that checks into a hash structure like the PSBoundParameters for a specific key. The key needs to be provided by name. But then a simple $PSBoundParameters.ContainsKey('MyParam') wherever you need it should suffice.
The problem with your code is that you are checking the $PSBoundParameters value of the Function itself, which has no parameters.
You could make the function work by sending the $PSBoundParameters variable from the script in to the function via a differently named parameter.
For example:
TestParam.psm1:
function Test-ScriptParameter ($BoundParameters) {
return $BoundParameters.ContainsKey('MyParam')
}
Export-ModuleMember -Function *
TestParam.ps1:
[CmdletBinding()]
param (
$MyParam = "Default"
)
$path = Join-Path (Split-Path -Path $PSCommandPath -Parent) 'TestParam.psm1'
Import-Module $path -ErrorAction Stop -Force
Test-ScriptParameter $PSBoundParameters
In the below sample module file, is there a way to pass the myvar value while importing the module.
For example,
import-module -name .\test.psm1 -?? pass a parameter? e.g value of myvar
#test.psm1
$script:myvar = "hi"
function Show-MyVar {Write-Host $script:myvar}
function Set-MyVar ($Value) {$script:myvar = $Value}
#end test.psm1
(This snippet was copied from another question.)
This worked for me:
You can use the –ArgumentList parameter of the import-module cmdlet to pass arguments when loading a module.
You should use a param block in your module to define your parameters:
param(
[parameter(Position=0,Mandatory=$false)][boolean]$BeQuiet=$true,
[parameter(Position=1,Mandatory=$false)][string]$URL
)
Then call the import-module cmdlet like this:
import-module .\myModule.psm1 -ArgumentList $True,'http://www.microsoft.com'
As may have already noticed, you can only supply values (no names) to –ArgumentList. So you should define you parameters carefully with the position argument.
Reference
The -ArgumentList parameter of Import-Module unfortunately does not accept a [hashtable] or [psobject] or something. A list with fixed postitions is way too static for my liking so I prefer to use a single [hashtable]-argument which has to be "manually dispatched" like this:
param( [parameter(Mandatory=$false)][hashtable]$passedVariables )
# this module uses the following variables that need to be set and passed as [hashtable]:
# BeQuiet, URL, LotsaMore...
$passedVariables.GetEnumerator() |
ForEach-Object { Set-Variable -Name $_.Key -Value $_.Value }
...
The importing module or script does something like this:
...
# variables have been defined at this point
$variablesToPass = #{}
'BeQuiet,URL,LotsaMore' -split ',' |
ForEach-Object { $variablesToPass[$_] = Get-Variable $_ -ValueOnly }
Import-Module TheModule -ArgumentList $variablesToPass
The above code uses the same names in both modules but you could of course easily map the variable names of the importing script arbitrarily to the names that are used in the imported module.
By default, any named function that has the [CmdletBinding()] attribute accepts the -debug and -verbose (and a few others) parameters and has the predefined $debug and $verbose variables. I'm trying to figure out how to pass them on to other cmdlet's that get called within the function.
Let's say I have a cmdlet like this:
function DoStuff() {
[CmdletBinding()]
PROCESS {
new-item Test -type Directory
}
}
If -debug or -verbose was passed into my function, I want to pass that flag into the new-item cmdlet. What's the right pattern for doing this?
$PSBoundParameters isn't what you're looking for. The use of the [CmdletBinding()] attribute allows the usage of $PSCmdlet within your script, in addition to providing a Verbose flag. It is in fact this same Verbose that you're supposed to use.
Through [CmdletBinding()], you can access the bound parameters through $PSCmdlet.MyInvocation.BoundParameters. Here's a function that uses CmdletBinding and simply enters a nested prompt immediately in order examine the variables available inside the function scope.
PS D:\> function hi { [CmdletBinding()]param([string] $Salutation) $host.EnterNestedPrompt() }; hi -Salutation Yo -Verbose
PS D:\>>> $PSBoundParameters
____________________________________________________________________________________________________
PS D:\>>> $PSCmdlet.MyInvocation.BoundParameters
Key Value
--- -----
Salutation Yo
Verbose True
So in your example, you would want the following:
function DoStuff `
{
[CmdletBinding()]
param ()
process
{
new-item Test -type Directory `
-Verbose:($PSCmdlet.MyInvocation.BoundParameters["Verbose"].IsPresent -eq $true)
}
}
This covers -Verbose, -Verbose:$false, -Verbose:$true, and the case where the switch is not present at all.
Perhaps it sounds strange, but there isn't any easy way for a cmdlet to know its verbose or debug mode. Take a look at the related question:
How does a cmdlet know when it really should call WriteVerbose()?
One not perfect, but practically reasonable, option is to introduce your own cmdlet parameters (for example, $MyVerbose and $MyDebug) and use them in the code explicitly:
function DoStuff {
[CmdletBinding()]
param
(
# Unfortunately, we cannot use Verbose name with CmdletBinding
[switch]$MyVerbose
)
process {
if ($MyVerbose) {
# Do verbose stuff
}
# Pass $MyVerbose in the cmdlet explicitly
New-Item Test -Type Directory -Verbose:$MyVerbose
}
}
DoStuff -MyVerbose
UPDATE
When we need only a switch (not, say, a verbosity level value) then the approach with $PSBoundParameters is perhaps better than proposed in the first part of this answer (with extra parameters):
function DoStuff {
[CmdletBinding()]
param()
process {
if ($PSBoundParameters['Verbose']) {
# Do verbose stuff
}
New-Item Test -Type Directory -Verbose:($PSBoundParameters['Verbose'] -eq $true)
}
}
DoStuff -Verbose
It's all not perfect anyway. If there are better solutions then I would really like to know them myself.
There is no need. PowerShell already does this as the code below proves.
function f { [cmdletbinding()]Param()
"f is called"
Write-Debug Debug
Write-Verbose Verbose
}
function g { [cmdletbinding()]Param()
"g is called"
f
}
g -Debug -Verbose
The output is
g is called
f is called
DEBUG: Debug
VERBOSE: Verbose
It is not done as direct as passing -Debug to the next cmdlet though. It is done through the $DebugPreference and $VerbrosePreference variables. Write-Debug and Write-Verbose act like you would expect, but if you want to do something different with debug or verbose you can read here how to check for yourself.
Here's my solution:
function DoStuff {
[CmdletBinding()]
param ()
BEGIN
{
$CMDOUT = #{
Verbose = If ($PSBoundParameters.Verbose -eq $true) { $true } else { $false };
Debug = If ($PSBoundParameters.Debug -eq $true) { $true } else { $false }
}
} # BEGIN ENDS
PROCESS
{
New-Item Example -ItemType Directory #CMDOUT
} # PROCESS ENDS
END
{
} #END ENDS
}
What this does different from the other examples is that it will repsect "-Verbose:$false" or "-Debug:$false". It will only set -Verbose/-Debug to $true if you use the following:
DoStuff -Verbose
DoStuff -Verbose:$true
DoStuff -Debug
DoStuff -Debug:$true
You could build a new hash table based on the bound debug or verbose parameters and then splat it to the internal command. If you're just specifying switches (and aren't passing a false switch, like $debug:$false) you can just check for the existence of debug or verbose:
function DoStuff() {
[CmdletBinding()]
PROCESS {
$HT=#{Verbose=$PSBoundParameters.ContainsKey'Verbose');Debug=$PSBoundParameters.ContainsKey('Debug')}
new-item Test -type Directory #HT
}
}
If you want to pass the parameter value it's more complicated, but can be done with:
function DoStuff {
[CmdletBinding()]
param()
PROCESS {
$v,$d = $null
if(!$PSBoundParameters.TryGetValue('Verbose',[ref]$v)){$v=$false}
if(!$PSBoundParameters.TryGetValue('Debug',[ref]$d)){$d=$false}
$HT=#{Verbose=$v;Debug=$d}
new-item Test -type Directory #HT
}
}
The best way to do it is by setting the $VerbosePreference. This will enable the verbose level for the entire script. Do not forget to disable it by the end of the script.
Function test
{
[CmdletBinding()]
param($param1)
if ($psBoundParameters['verbose'])
{
$VerbosePreference = "Continue"
Write-Verbose " Verbose mode is on"
}
else
{
$VerbosePreference = "SilentlyContinue"
Write-Verbose " Verbose mode is Off"
}
# <Your code>
}
You can set the VerbosePreference as a global variable on starting your script and then check for the global variable in your custom cmdlet.
Script:
$global:VerbosePreference = $VerbosePreference
Your-CmdLet
Your-CmdLet:
if ($global:VerbosePreference -eq 'Continue') {
# verbose code
}
Checking explicitly for 'Continue' allows the script to be equal to -verbose:$false when you call the CmdLet from a script that doesn't set the global variable (in which case it's $null)
You do not have to do any checks or comparisons. Even though -Verbose (and -Debug) are of type [switch], they seem to understand not just $true and $false but also their preference variable. The preference variable also gets inherited correctly to all child functions that are called. I tried this on Powershell version 7.3.2 and it works as expected.
function Parent {
[CmdletBinding()]param()
Child
}
function Child {
[CmdletBinding()]param()
New-Item C:\TEST\SomeDir -Force -ItemType Directory -Verbose:$VerbosePreference -Debug:$DebugPreference
}
Parent -Verbose
Parent -Debug
I think this is the easiest way:
Function Test {
[CmdletBinding()]
Param (
[parameter(Mandatory=$False)]
[String]$Message
)
Write-Host "This is INFO message"
if ($PSBoundParameters.debug) {
Write-Host -fore cyan "This is DEBUG message"
}
if ($PSBoundParameters.verbose) {
Write-Host -fore green "This is VERBOSE message"
}
""
}
Test -Verbose -Debug