Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Need straightforward way to honor the caller's preference-variable values / inherited common parameters in functions defined in script modules #4568

Closed
mklement0 opened this issue Aug 14, 2017 · 57 comments
Labels
Issue-Discussion the issue may not have a clear classification yet. The issue may generate an RFC or may be reclassif Issue-Enhancement the issue is more of a feature request than a bug Resolution-No Activity Issue has had no activity for 6 months or more WG-Language parser, language semantics

Comments

@mklement0
Copy link
Contributor

mklement0 commented Aug 14, 2017

This is a longstanding issue that @dlwyatt explained in detail in this 2014 blog post, and he has even published module PreferenceVariables with advanced function Get-CallerPreference to ease the pain, via a hard-coded list of preference variable names.

In short: A script module's functions do not see the preference-variable values set in the caller's context (except if that context happens to be the global one), which means that the caller's preferences are not honored.

Update: Since implicitly setting preference variables is PowerShell's method for propagating (inheriting) common parameters such as -WhatIf, such parameters are ultimately not honored in calls to script-module functions when passed via an advanced function - see #3106, #6556, and #6342. In short: the common-parameter inheritance mechanism is fundamentally broken for advanced functions across module scopes, which also affects standard modules such as NetSecurity and Microsoft.PowerShell.Archive .

  • From a user's perspective this is (a) surprising and (b), once understood, inconvenient.
    Additionally, given that compiled cmdlets do not have the same problem, it is not easy to tell in advance which cmdlets / advanced functions are affected. In a similar vein, compiled cmdlets proxied via implicitly remoting modules also do not honor the caller's preferences.

  • From a developer's perspective, it is (a) not easy to keep the problem in mind, and (b) addressing the problem requires a workaround that is currently quite cumbersome, exacerbated by currently not having a programmatic way identify all preference variables in order to copy their values to the callee's namespace - see Improving the discoverability of PowerShell variables #4394.

A simple demonstration of the problem:

# Define an in-memory module with a single function Foo() that provokes
# a non-terminating error.
$null = New-Module {
  function Foo {
    [CmdletBinding()] param()
    # Provoke a non-terminating error.
    Get-Item /Nosuch
  }
}

# Set $ErrorActionPreference in the *script* context:
# Request that errors be silenced.
# (Note: Only if you defined this in the *global* context would the module see it.)
$ErrorActionPreference = 'SilentlyContinue'

# Because the module in which Foo() is defined doesn't see this script's
# variables, it cannot honor the $ErrorActionPreference setting, and 
# the error message still prints.
Foo

Desired behavior

No output.

Current behavior

Get-Item : Cannot find path '/Nosuch' because it does not exist.
...

Environment data

PowerShell Core v6.0.0-beta.5 on macOS 10.12.6
PowerShell Core v6.0.0-beta.5 on Ubuntu 16.04.3 LTS
PowerShell Core v6.0.0-beta.5 on Microsoft Windows 10 Pro (64-bit; v10.0.15063)
Windows PowerShell v5.1.15063.483 on Microsoft Windows 10 Pro (64-bit; v10.0.15063)
@iSazonov iSazonov added the Issue-Discussion the issue may not have a clear classification yet. The issue may generate an RFC or may be reclassif label Aug 14, 2017
@iSazonov
Copy link
Collaborator

Module has its own context. It may be preferable to set the preference variable value for the module
Set-Variable -Module moduleName

@mklement0
Copy link
Contributor Author

mklement0 commented Aug 14, 2017

Yes, modules have their own scope, which is generally a good thing.

However, this special case calls for a concise, PS-supported way to copy all the preference-variable values from the caller's scope.
(The current workaround of using $PSCmdlet.SessionState.PSVariable based on a hard-coded list of preference variable names is neither future-proof nor reasonable.)

Consider the user's perspective:

If $ErrorActionPreference = 'Ignore'; Get-Item /NoSuch works,
$ErrorActionPreference = 'Ignore'; Get-MyItem /NoSuch not working, just because the Get-MyItem command happens to be defined as an advanced function in a script module, is highly obscure and confusing.

Giving module developers an easy, built-in way to opt into the caller's preferences would mitigate that problem.

@SteveL-MSFT SteveL-MSFT added WG-Language parser, language semantics Issue-Enhancement the issue is more of a feature request than a bug labels Aug 15, 2017
@SteveL-MSFT SteveL-MSFT added this to the 6.1.0 milestone Aug 15, 2017
@iSazonov
Copy link
Collaborator

iSazonov commented Aug 15, 2017

Main module's function is to isolate code that expose public API and hide implementation. We can get unpredictable behavior if we just copy any variables (and maybe indirectly functions) to a module context.
Also internally there are difference of global and local preference variables.
Preference variables is not protected and can be used other ways:

Remove-Variable ErrorActionPreference
$ErrorActionPreference =@()

We may have problems if these variables are simply copied in a module context.

What scenarios do we need to? It seems it is only debug. If so we could resolve this otherwise, for example, by loading a module in debug mode. If we want to manage the preference variables in a module, we must do this in a predictable way (We can load tens modules in session!):

Import-Module testModule -SetErrorActionPreference Stop

@iSazonov
Copy link
Collaborator

iSazonov commented Aug 15, 2017

Also we have an Issue with the request to share modules between runspaces. If we want implement this we can not simply copy any variables - no single parent context exists.

@mklement0
Copy link
Contributor Author

Preference variables is not protected and can be used other ways:

Indeed, but that is (a) incidental to the issue at hand and (b) should in itself be considered a bug: see #3483

@mklement0
Copy link
Contributor Author

We can get unpredictable behavior if we just copy any variables (and maybe indirectly functions) to a module context.

We're not talking about any variables. We're talking about the well-defined set of preference variables - even though, as stated, there's currently no programmatic way to enumerate them.

@mklement0
Copy link
Contributor Author

mklement0 commented Aug 15, 2017

What scenarios do we need to? It seems it is only debug.

  • Please read the blog post that the original post links to.
  • Then consider my Get-Item vs. Get-MyItem example.

This is very much a production issue, unrelated to debugging:

Currently, if you create a script module, that module's functions will by default ignore a caller's preferences variables (unless you're calling from the global scope), which to me is self-evidently problematic:

For instance, if a caller sets $ErrorActionPreference to Stop, they have a reasonable expectation that cmdlets / advanced functions invoked subsequently honor that setting - without having to know whether a given command happens to be defined in a script module that doesn't see that setting by default.

@lzybkr
Copy link
Member

lzybkr commented Aug 15, 2017

Honoring the callers preference might cause serious problems by introducing exceptions (e.g. $errorActionPreference = 'Stop' in places where none were expected.

Some examples - Dispose might not get called and resources leak, or the system may be left in an inconsistent state because some side effects do not execute.

If the goal is to silence a noisy function - one can work with the function author to provide a better experience or else explicitly pass -ErrorActionPrefernce SilentlyContinue.

@dlwyatt
Copy link
Contributor

dlwyatt commented Aug 15, 2017

I think the focus is too much on the implementation here and not on the problem: a user shouldn't have to know or care how a particular PowerShell command is implemented. Whether it's a cmdlet, function, cdxml, imported from a remote session, whatever: they should have a consistent experience with regard to common parameters and preference variables. If I put $VerbosePreference = 'Continue' in my script, I should expect to see all the verbose output that I asked for. If I say $ErrorActionPreference = 'Stop', then anything that bubbles up through the Error stream to my code should cause a terminating error.

Honoring the callers preference might cause serious problems by introducing exceptions (e.g. $errorActionPreference = 'Stop' in places where none were expected.

I don't see this as a problem, since that's exactly what happens if the caller specifies -ErrorAction Stop today. (Common parameters cause the equivalent preference variables to be set in the advanced function's scope.)

@mklement0
Copy link
Contributor Author

@dlwyatt: Amen to that. I was in the middle of composing the following:

The point is that if compiled cmdlets respect preference variables set in the caller's scope, it's reasonable to expect advanced functions to respect them too - the user shouldn't have to worry about module scopes or how a given command happens to be implemented.

With respect to $ErrorActionPreference = 'Stop': It sounds like using Stop (whether via the pref. variable or the common parameter) is inherently problematic, which is a separate conversation (and I personally wasn't aware that it's problematic - probably worth documenting).

Let's take a more innocuous example: setting $VerbosePreference = 'Continue' in an effort to make all cmdlet invocations in the current script produce verbose output:

The following example defines Get-Foo1 as a compiled cmdlet, and functionally equivalent Get-Foo2 as an advanced function in a module:

Get-Foo1 respects the verbose preference, Get-Foo2 does not.

# Define compiled cmdlet Get-Foo1 (via an in-memory assembly and module).
Add-Type -TypeDefinition @'
    using System;
    using System.Management.Automation;
    [Cmdlet("Get", "Foo1")]
    public class GetFoo1 : PSCmdlet {

        protected override void EndProcessing() {
            WriteVerbose("foo1");
        }
    }
'@ -PassThru | % Assembly | Import-Module

# Define analogous advanced function Get-Foo2 via an in-memory module.
$null = New-Module {
    Function Get-Foo2 {
        [cmdletbinding()]
        param()

        End {
            Write-Verbose("foo2");
        }
    }
}

$VerbosePreference = 'Continue'

# Compiled cmdlet respects $VerbosePreference.
Get-Foo1
# Verbose: foo1

# Advanced function in script module does NOT, because it doesn't see the caller's
# $VerbosePreference variable.
Get-Foo2
# (no output)

Expecting the user to even anticipate a distinction here and to then know which commands are affected based on how they happen to be implemented strikes me as highly obscure.

A scenario in which the behavior is even more obscure is with implicit remoting modules that proxy compiled cmdlets that execute remotely. In that case, the remotely executing cmdlets, even though they normally respect the caller's preference, do not. (On a related note: even -ErrorAction Stop does not work in that scenario, because the parameter is applied remotely, and terminating errors that occur remotely are by design converted to non-terminating ones.)


I don't know what the right solution is, but if we can agree that there is a problem, we can tackle it.

@lzybkr
Copy link
Member

lzybkr commented Aug 15, 2017

Keep in mind one big difference between binary cmdlets and functions - binary cmdlets are rarely implemented in terms of PowerShell functions or other cmdlets.

The error or verbose output is likely carefully crafted for a binary cmdlet, whereas it's a bit more random for functions.

Also note that the equivalence of -ErrorAction Stop and setting the preference variable could be thought of as an implementation detail and is considered a bug by some people for some of the reasons mentioned above.

It's worth pointing out that extra verbosity is not always desirable - and this proposal could turn some useful verbose output into noisy useless output.

@mklement0
Copy link
Contributor Author

Keep in mind one big difference between binary cmdlets and functions - binary cmdlets are rarely implemented in terms of PowerShell functions or other cmdlets.

Users needing to be aware of how a given command happens to be implemented is an unreasonable expectation.
Aside from that, I don't understand your comment.

The error or verbose output is likely carefully crafted for a binary cmdlet, whereas it's a bit more random for functions.

PowerShell has evolved toward making the PowerShell language a first-class citizen with respect to creating cmdlets (advanced functions).
Someone skilled enough to create advanced functions as part of a module should be assumed to exercise the same care as someone creating a compiled cmdlet.

It's worth pointing out that extra verbosity is not always desirable - and this proposal could turn some useful verbose output into noisy useless output.

If I, as a user, set $VerbosePreference = 'Continue' scope-wide, I expect all commands invoked subsequently to honor that settings; if I didn't want that, I would use -Verbose on a per-command basis.

Note that backward compatibility is a separate discussion - opting in to copying the caller's preference variables is a viable option in that context, perhaps via a new [System.Management.Automation.CmdletBindingAttribute] property such as [CmdletBinding(UseCallerPreferences)].

the equivalence of -ErrorAction Stop and setting the preference variable

Again I don't fully understand your comment; at the risk of going off on a tangent:

They are not equivalent: -ErrorAction Stop only affects non-terminating errors (escalates them to script-terminating errors); $ErrorActionPreference = 'Stop' affects both non-terminating and statement-terminating errors - see Our Error Handling, Ourselves - time to fully understand and properly document PowerShell's error handling.

@lzybkr
Copy link
Member

lzybkr commented Aug 16, 2017

Users needing to be aware

I think you're emphasizing my point. Users of a command aren't aware of the implementation and do have the expectation of reasonable output.

Command authors are a different story - they need to be aware of the implications of preference variables and parameters like -Verbose in a way that differs from the user.

In some cases, the command author and user are the same, and I do wonder if that's where some of this feedback is coming from - because the command author sometimes wants more help debugging during development.

@mklement0
Copy link
Contributor Author

Users of a command aren't aware of the implementation and do have the expectation of reasonable output.

No argument there. And let's not forget predictable behavior, which brings us to the next point:

I do wonder if that's where some of this feedback is coming from

I can't speak for @dlwyatt, but to me this is all about the user perspective:

  • I set a preference variable to modify the behavior scope-wide.
  • I expect commands called from that scope to honor them, just as the standard cmdlets do.

If I can't predict which of the commands I'll invoke will actually honor the preferences, I might as well do without preferences altogether and only use common parameters.

We've covered $VerbosePreference, but let's consider $WhatIfPreference and $ConfirmPreference:

If I set these with the expectations that all commands invoked from the same scope will honor them, I may be in for nasty surprises.

Similarly, to return to $ErrorActionPreference: An approach I've personally used it to set $ErrorActionPreference = 'Stop' at the start of a script as fallback and then handle errors that I anticipate on a per-command basis. That way, unanticipated errors cause the script to abort, as a fail-safe - or so I'd hoped.

@mklement0
Copy link
Contributor Author

mklement0 commented Aug 18, 2017

Also note that the equivalence of -ErrorAction Stop and setting the preference variable could be thought of as an implementation detail and is considered a bug by some people for some of the reasons mentioned above.

I think I now understand what you're saying, and I hope I'm not wasting my breath based on a misinterpretation:

It sounds like you're conceiving of preference variables as being limited to the current scope only, without affecting its descendant scopes or the potentially completely separate scopes of commands invoked.

  • By itself, this conception runs counter to how variables work in PowerShell, at least with respect to descendant scopes.
    To be consistent with how PS variables work, you'd have to define a variable as $private: in order to limit it to the current scope only.

  • Certainly, given that core cmdlets currently do honor the preference variables, changing that behavior - selectively, for preference variables only - would be a serious breaking change and would require a focused effort to reframe the purpose of preference variables.


Leaving the syntax and backward compatibilities issues aside, I can see how some preference variables can be helpful as limited to the current scope:

  • $DebugPreference and $VerbosePreference and $ErrorActionPreference only applied to Write-Debug and Write-Verbose and Write-Error commands issued directly in the current scope could help with debugging only a given script, without being distracted by debug/verbose commands / errors from commands invoked from the current scope.
    As an aside: As stated, against documented behavior, $ErrorActionPreference currently also takes effect for terminating errors.

    • Again: In the realm of PowerShell variables, that would call for something like $private:VerbosePreference = 'Continue'. On a side note: that doesn't actually work - descendant scopes still see the value, which means that any non-module-defined functions currently do respect these preferences either way.
  • And, again, just as I and @dlwyatt have, I assume that many people are currently relying on $ErrorActionPreference applying to errors reported by commands called from the current scope too - or at least have had that expectation, which currently only holds true for compiled cmdlets by default, a distinction that brings us back to the original problem.

@StingyJack
Copy link
Contributor

StingyJack commented Feb 8, 2018

I found that specifying $global: addresses this for progress bar issues.

$global:ProgressPreference = 'silentlycontinue' at the top of a script suppresses the performance killing progress indicator when downloading things.

It's probably easier to do that with VerbosePreference than adding -Verbose:($PsCmdlet.MyInvocation.BoundParameters['verbose'] -eq $true) to every single command my script calls.

@alx9r
Copy link

alx9r commented Feb 28, 2018

It seems to me that when considering code inside a script module there are two distinct kinds of code to which preference variables (eg. $ErrorActionPreference, etc) and common parameters (eg. -ErrorAction, etc) might apply:

  • business logic implemented within the module
    • The cause of errors arising from such code are bugs (ie. "boneheaded" errors per Eric Lippert's taxonomy). Accordingly, such code should be run with $ErrorActionPreference='Stop'.
    • Deciding the conditions for and quantity of verbose messages output from the business logic requires the good judgment of the module author. This probably involves careful consideration of what should happen when the module is called with the different values of $VerbosePreference and -Verbose at the call site of module entry points.
    • $DebugPreference, $WarningPreference, and $InformationPreference for such code should probably be the PowerShell defaults regardless of what the caller's values of those variable are.
  • calls made to outside the module
    • Such code is subject to all sorts of untidy unpredictable external realities that cause errors (Eric Lippert calls these "exogenous"). The user of the module should be the party deciding what to do when such an error arises. Accordingly,
    • the value of common parameters and preference variables for such calls should be the same as they were at the call site of the entry point to the module.

The above points aren't hard and fast, but I think they're a reasonable starting point for most modules involving a mix of business logic and calls made to outside the module. PowerShell, of course, does not behave this way by default. To implement a module that is consistent with the above points requires two things to happen as follows:

  1. Capture preference variables and common parameters at the call site of the entry point to the module.
  2. Apply those preference variables and common parameters to the calls made to outside the module.

I suspect that these tasks can be handled reasonably well by a utility module. I wrote an annotated proof-of-concept of such a module. The entry points to a user module that uses the utility module would look, for example, like this:

function Get-MyItem {
    param(
        [Parameter(Position=1)] $Path
    )
    HonorCallerPrefs {
        Get-MyItemImpl $Path
    }
}

function New-MyItem {
    param(
        [Parameter(Position=1)] $Path,
        [switch]                $WhatIf,
        [switch]                $Confirm
    )
    HonorCallerPrefs {
        New-MyItemImpl $Path
    }
}

The corresponding sites that call out of the user module would look like this:

InvokeWithCallerPrefs {
    Get-Item $path @CallerCommonArgs
}

InvokeWithCallerPrefs {
    New-Item $path @CallerCommonArgs
}

HonorCallerPrefs{} and InvokeWithCallerPrefs{} are implemented by the utility module and capture and apply, respectively, the preference variables and common parameters. The concept should work even if the call stack between HonorCallerPrefs{} and InvokeWithCallerPrefs{} is deep and complicated, as long as the calls to HonorCallerPrefs{} and InvokeWithCallerPrefs{} are in the same module.

@mklement0
Copy link
Contributor Author

mklement0 commented Apr 4, 2018

The recent #6556 is a more pernicious manifestation of the problem discussed here:

Because CDXML-based cmdlets are seemingly advanced functions rather than binary cmdlets, the functions in the NetSecurity module such as Enable-NetFirewallRule situationally do not honor the -WhatIf switch, namely if invoked via an advanced function that itself supports -WhatIf or via an explicitly set $WhatIfPreference value, as discussed.

Revisiting @lzybkr's comment:

Also note that the equivalence of -ErrorAction Stop and setting the preference variable could be thought of as an implementation detail

From what I gather, PowerShell implicitly translating a common parameter such as -WhatIf into the equivalent locally scoped preference variable such as $WhatIfPreference is the mechanism for automatically propagating common parameters to calls to other cmdlets inside an advanced function.
So, yes, you can conceive of it as an implementation detail, but even as such it is broken.

@alx9r: As commendable as starting scripts / functions with $ErrorActionPreference = 'Stop' is (it is what I usually do in my own code), it actually interferes with that mechanism (I have yet to take a look at your proof of concept).

@mklement0
Copy link
Contributor Author

#6342 is a similar manifestation of the problem, in the context of Expand-Archive.

@mklement0 mklement0 changed the title Need straightforward way to honor the caller's preference-variable values in functions defined in script modules Need straightforward way to honor the caller's preference-variable values / relayed common parameters in functions defined in script modules Apr 4, 2018
@ghost
Copy link

ghost commented Dec 30, 2022

I hit this problem writing my first module. I'm going to revert back to sourcing scripts directly (. .\foo.ps1), and not use advanced-function modules going forward.

9+ years for a problem of this impact seems like a long time. Are advanced-function modules considered second-class citizens in PowerShell? Are developers writing binary modules instead? Or is there just very little developer involvement at the module level in general?

@JustinGrote
Copy link
Contributor

@mc-ganduron most likely the issue is complexity of implementation vs. relatively easy workarounds (passing the preference thru to child functions).

I would say abandoning advanced functions for just this is a little dramatic don't you think?

@ghost
Copy link

ghost commented Dec 30, 2022

I would say abandoning advanced functions for just this is a little dramatic don't you think?

Not at all. First, I have a prior solution available with no workarounds required (sourcing scripts directly). Second, it's reasonable to take first impressions into account. My first experience with advanced-function modules led me to a 9+ year-old bug. I'm not willing to march ahead into other old bugs when I have options available, within PowerShell and without.

@StingyJack
Copy link
Contributor

I'm not willing to march ahead into other old bugs when I have options available

@mc-ganduron - All software has bugs. Lots of them are old. This one bothered me as well until the day I was troubleshooting a problem with my module and I only wanted to have verbose output from my module and did not want all of the verbose output noise from all of the other modules and functions my module called. This current way does not seem as convenient or consistent at first, but it does represent an "opt-in" style choice that is more useful than opting out for many troubleshooting scenarios.

@ili101
Copy link

ili101 commented Dec 31, 2022

Actually I noticed that for errors $PSCmdlet.ThrowTerminatingError() works exactly like compiled cmdlet and honor the caller's preference:

$null = New-Module {
    $ErrorActionPreference = 'Stop'
    function Foo {
        [CmdletBinding()] param()
        trap { $PSCmdlet.ThrowTerminatingError($_) }
        Get-Item /Nosuch
    }
}

$ErrorActionPreference = 'SilentlyContinue'
Foo

I think that it's the most reliable way to handle errors from modules.
I wish the default behavior was like the example above without needing to re-throw the errors. And maybe a way to do the same thing for streams other then errors.

@JustinGrote
Copy link
Contributor

I do wish the syntax for $PSCmdlet.WriteError and ThrowTerminating error was more terse, it looks like black magic when it really should be the way to do it, something like throwUser keyword.

@PowerCoder
Copy link

I have been scratching my head for months why I didn't get my Debug, Verbose and Information messages in my output when calling Functions in Modules. It's funny when they advise us to move from Write-Host to Write-Information and then make InformationPreference SilentlyContinue the default.

By accident I found this thread and I now prefix DebugPreference, VerbosePreference and InformationPreference with $Global and suddenly I get everything I've been missing, all the feedback I had programmed into my functions but could only see when running from my local system.

So this is absolutely still a thing on PowerShell 7, Mr. Bot.

@drstonephd
Copy link

This is so ugly. I want my debug messages and have no problem getting them. Somehow a module gets the preference. I try as I might to disable the module messages, but to no avail. I don't know if it's the module code trying to get the preference via some tricky means because of this issue or something else. Really, perhaps leave this as version 1 of messaging and create a completely new implementation. And make the new implementation easy to use. A transcript option would be nice if we don't want to liter code with write-debug when we want to see everything. A message level instead many preferences. Multiple targets. Bite the bullet and think of something both easy and useful. Don't make it so we are stuck with backward compatible horrors. I'm sick of this thing.

@drstonephd
Copy link

drstonephd commented Dec 27, 2023

I don't remember how many ways I tried to suppress module debug messages without suppressing mine. (I'm debugging my code, not the modules.) Here is another attempt that seems to work. (I'm holding my breath.)

In the function I define a private set of preference variables if they are different from the desired module preferences. The function will use the private set. A global set of preferences are defined - after the private set is defined - with the desired module preferences. Before exiting the function, the global values are returned to their original values. I'm experienced with PowerShell, but I in no way consider myself an expert. This seems fragile and clunky. Constructive criticism very welcome. (If you don't think I know what I'm doing, that probably makes two of us.)

function Set-StandardDatabaseMail {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = "Low")]
    param (
        [parameter(Mandatory, ValueFromPipeline)]
        [string[]]$SqlInstance,
        [PSCredential]$SqlCredential,
        [switch]$Force
    )

    begin {

        $fnName = $PSCmdlet.MyInvocation.MyCommand.Name

        Write-Debug ('[{0}] BEGIN enter' -f $fnName)

        if (-not $PSBoundParameters.ContainsKey('Debug')) {
            $DebugPreference = $PSCmdlet.SessionState.PSVariable.GetValue('DebugPreference')
        }
        Write-Debug ('[{0}] $DebugPreference={1}' -f $fnName, $DebugPreference)

        if (-not $PSBoundParameters.ContainsKey('Verbose')) {
            $VerbosePreference = $PSCmdlet.SessionState.PSVariable.GetValue('VerbosePreference')
        }
        Write-Debug ('[{0}] $VerbosePreference={1}' -f $fnName, $VerbosePreference)

        if (-not $PSBoundParameters.ContainsKey('InformationAction')) {
            $InformationPreference = $PSCmdlet.SessionState.PSVariable.GetValue('InformationPreference')
        }
        Write-Debug ('[{0}] $InformationPreference={1}' -f $fnName, $InformationPreference)

        if ($Force -and -not $Confirm){ # Force disables confirm unless the user explicitly requested it
            $ConfirmPreference = 'None'
            Write-Debug ('[{0}] $ConfirmPreference={1} (Force)' -f $fnName, $ConfirmPreference)
        } else {
            if (-not $PSBoundParameters.ContainsKey('Confirm')) {
                $ConfirmPreference = $PSCmdlet.SessionState.PSVariable.GetValue('ConfirmPreference')
            }
            Write-Debug ('[{0}] $ConfirmPreference={1}' -f $fnName, $ConfirmPreference)
        }

        if (-not $PSBoundParameters.ContainsKey('WhatIf')) {
            $WhatIfPreference = $PSCmdlet.SessionState.PSVariable.GetValue('WhatIfPreference')
        }
        Write-Debug ('[{0}] $WhatIfPreference={1}' -f $fnName, $WhatIfPreference)

        $moduleDebugPreference = 'SilentlyContinue' # for the DBATools module
        Write-Debug ('[{0}] $moduleDebugPreference={1}' -f $fnName, $moduleDebugPreference)

        $moduleVerbosePreference = 'SilentlyContinue' # for the DBATools module
        Write-Debug ('[{0}] $moduleVerbosePreference={1}' -f $fnName, $moduleVerbosePreference)

        # hack custom module preferences 

        if ($moduleDebugPreference -ne $DebugPreference) {
            $private:DebugPreference = $DebugPreference
            $global:DebugPreference = $moduleDebugPreference
            Write-Debug ('[{0}] $DebugPreference={1}' -f $fnName, $DebugPreference)
            Write-Debug ('[{0}] $private:DebugPreference={1}' -f $fnName, $private:DebugPreference)
            Write-Debug ('[{0}] $global:DebugPreference={1}' -f $fnName, $global:DebugPreference)
        }

        if ($moduleVerbosePreference -ne $DebugPreference) {
            $private:VerbosePreference = $VerbosePreference
            $global:VerbosePreference = $moduleVerbosePreference
            Write-Debug ('[{0}] $VerbosePreference={1}' -f $fnName, $VerbosePreference)
            Write-Debug ('[{0}] $private:VerbosePreference={1}' -f $fnName, $private:VerbosePreference)
            Write-Debug ('[{0}] $global:VerbosePreference={1}' -f $fnName, $global:VerbosePreference)
        }

    }

    process {

        Write-Debug ('[{0}] PROCESS enter' -f $fnName)

        # The DBA team uses the same settings, account, and profile for email alerts 
        # related to database maintenance on each instance.  Those values that depend upon 
        # instance name will follow the same pattern.

        $accountName = 'MailAccountName'
        $description = 'Mail account for SMTP e-mail.'
        $emailDomain = 'ouremaildomain.gov'
        $mailServer = 'oursmptserverfqdn'
        $useDefaultCredentials = $true # use the service account to access the mail server

        Write-Debug ('[{0}] $accountName={1}' -f $fnName, $accountName)
        Write-Debug ('[{0}] $description={1}' -f $fnName, $description)
        Write-Debug ('[{0}] $emailDomain={1}' -f $fnName, $emailDomain)
        Write-Debug ('[{0}] $mailServer={1}' -f $fnName, $mailServer)
        Write-Debug ('[{0}] $useDefaultCredentials={1}' -f $fnName, $useDefaultCredentials)

        foreach ($instance in $SqlInstance) {

            # We need to create a compatible display name and email address from the server name
            # that does not have a backslash.  If a FQDN is provided, the domain part is removed.

            $computerName = $instance.split('\',2)[0].split('.',2)[0].ToUpper()
            $instanceName = $instance.split('\', 2)[1].ToUpper()

            Write-Debug ('[{0}] $computerName={1}' -f $fnName, $computerName)
            Write-Debug ('[{0}] $instanceName={1}' -f $fnName, $instanceName)

            if ($instanceName) { 
                $instanceShort = $computerName + '\' + $instanceName 
            } else {
                $instanceShort = $computerName
            }

            Write-Debug ('[{0}] $instanceShort={1}' -f $fnName, $instanceShort)

            $accountSplat = @{
                'Account' = $accountName
                'DisplayName' = $instanceShort
                'Description' = $description
                'EmailAddress' = $instanceShort.replace('\', '_').ToLower() + '@' + $emailDomain
                'ReplyToAddress' = 'do_not_reply@' + $emailDomain
                'MailServer' = $mailServer
            }

            Write-Debug ('[{0}] $accountSplat.EmailAddress={1}' -f $fnName, $accountSplat.EmailAddress)
            Write-Debug ('[{0}] $accountSplat.ReplyToAddress={1}' -f $fnName, $accountSplat.ReplyToAddress)

            try {
                $account = Get-DbaDbMailAccount -SqlInstance $instance -Account $accountSplat.Account -EnableException
            }
            catch {
                Write-Warning ('[{0}] {1} : Failed to access db mail account {2}' -f $fnName, $instanceShort, $accountName)
                Continue
            }

            # Create the account if it does not exist; otherwise, check if it needs updated.

            if (-not $account) {
                if( $PSCmdlet.ShouldProcess($instance, "Creating new db mail account called $accountName") ) {

                    try {
                        $account = New-DbaDbMailAccount -SqlInstance $sqlInstance @accountSplat -WhatIf:$WhatIfPreference -Confirm:$false
                        }
                    catch {
                        Write-Warning ('[{0}] {1} : Failed to create db mail account {2}' -f $fnName, $instanceShort, $accountName)
                        Continue
                    }
    
                    $account.DisplayName = $accountSplat.DisplayName
                    $account.Description = $accountSplat.Description
                    $account.EmailAddress = $accountSplat.EmailAddress
                    $account.ReplyToAddress = $accountSplat.ReplyToAddress
                    $account.MailServers[0].Name = $accountSplat.MailServer
                    $account.MailServers[0].UseDefaultCredentials = $useDefaultCredentials
                    $account.Alter() # saves all changes to sql instance
                    Write-Information ('[{0}] {1}-{2} : Account Created' -f $fnName, $instanceShort, $accountName)
                }

            } else {

                $accountDirty = $false
            
                if ($account.DisplayName -ne $accountSplat.DisplayName) {
                    $oldDisplayName = $account.DisplayName
                    $account.DisplayName = $accountSplat.DisplayName
                    $accountDirty = $true
                    Write-Verbose ('[{0}] {1}-{2}-DisplayName : {3} -> {4}' -f $fnName, $instanceShort, $accountName, $oldDisplayName, $account.DisplayName)
                }
    
                if ($account.Description -ne $accountSplat.Description) {
                    $oldDescription = $account.Description
                    $account.Description = $accountSplat.Description
                    $accountDirty = $true
                    Write-Verbose ('[{0}] {1}-{2}-Description : {3} -> {4}' -f $fnName, $instanceShort, $accountName, $oldDescription, $account.Description)
                }
    
                if ($account.EmailAddress -ne $accountSplat.EmailAddress) {
                    $oldEmailAddress = $account.EmailAddress
                    $account.EmailAddress = $accountSplat.EmailAddress
                    $accountDirty = $true
                    Write-Verbose ('[{0}] {1}-{2}-EmailAddress : {3} -> {4}' -f $fnName, $instanceShort, $accountName, $oldEmailAddress, $account.EmailAddress)
                }
    
                if ($account.ReplyToAddress -ne $accountSplat.ReplyToAddress) {
                    $oldReplyToAddress = $account.ReplyToAddress
                    $account.ReplyToAddress = $accountSplat.ReplyToAddress
                    $accountDirty = $true
                    Write-Verbose ('[{0}] {1}-{2}-ReplyToAddress : {3} -> {4}' -f $fnName, $instanceShort, $accountName, $oldReplyToAddress, $account.ReplyToAddress)
                }
    
                if ($account.MailServers[0].Name -ne $accountSplat.MailServer) {
                    $oldMailServer = $account.MailServers[0].Name
                    $account.MailServers[0].Name = $accountSplat.MailServer
                    $accountDirty = $true
                    Write-Verbose ('[{0}] {1}-{2}-MailServer : {3} -> {4}' -f $fnName, $instanceShort, $accountName, $oldMailServer, $account.MailServers[0].Name)
                }
    
                if ($account.MailServers[0].UseDefaultCredentials -ne $useDefaultCredentials) {
                    $oldUseDefaultCredentials = $account.MailServers[0].UseDefaultCredentials
                    $account.MailServers[0].UseDefaultCredentials = $useDefaultCredentials
                    $accountDirty = $true
                    Write-Verbose ('[{0}] {1}-{2}-UseDefaultCredentials : {3} -> {4}' -f $fnName, $instanceShort, $accountName, $oldUseDefaultCredentials, $account.MailServers[0].UseDefaultCredentials)
                }

                if ($accountDirty -and $PSCmdlet.ShouldProcess($instance, "Saving changes to db mail account $accountName")) {
                    $account.Alter() # save all changes to sql instance
                    Write-Information ('[{0}] {1}-{2} : Account Updated' -f $fnName, $instanceShort, $accountName)
                }
    
            }
            
        }
    }

    end {

        # un-hack custom module preferences 

        if ($global:DebugPreference -ne $private:DebugPreference) {
            $global:DebugPreference = $private:DebugPreference
            Write-Debug ('[{0}] $global:DebugPreference={1}' -f $fnName, $global:DebugPreference)
        }
 
        if ($global:VerbosePreference -ne $private:DebugPreference) {
            $global:VerbosePreference = $private:VerbosePreference
            Write-Debug ('[{0}] $global:VerbosePreference={1}' -f $fnName, $global:VerbosePreference)
        }

        Write-Debug ('[{0}] END' -f $fnName)
    }

}
$DebugPreference = 'Continue'
$VerbosePreference = 'Continue'
Set-StandardDatabaseMail -SqlInstance XXX -WhatIf -InformationAction Continue

DEBUG: [Set-StandardDatabaseMail] BEGIN enter
DEBUG: [Set-StandardDatabaseMail] $DebugPreference=Continue
DEBUG: [Set-StandardDatabaseMail] $VerbosePreference=Continue
DEBUG: [Set-StandardDatabaseMail] $InformationPreference=Continue
DEBUG: [Set-StandardDatabaseMail] $ConfirmPreference=High
DEBUG: [Set-StandardDatabaseMail] $WhatIfPreference=True
DEBUG: [Set-StandardDatabaseMail] $moduleDebugPreference=SilentlyContinue
DEBUG: [Set-StandardDatabaseMail] $moduleVerbosePreference=SilentlyContinue
DEBUG: [Set-StandardDatabaseMail] $DebugPreference=Continue
DEBUG: [Set-StandardDatabaseMail] $private:DebugPreference=Continue
DEBUG: [Set-StandardDatabaseMail] $global:DebugPreference=SilentlyContinue
DEBUG: [Set-StandardDatabaseMail] $VerbosePreference=Continue
DEBUG: [Set-StandardDatabaseMail] $private:VerbosePreference=Continue
DEBUG: [Set-StandardDatabaseMail] $global:VerbosePreference=SilentlyContinue
DEBUG: [Set-StandardDatabaseMail] PROCESS enter
DEBUG: [Set-StandardDatabaseMail] $accountName=xxx
DEBUG: [Set-StandardDatabaseMail] $description=Mail account for SMTP e-mail.
DEBUG: [Set-StandardDatabaseMail] $emailDomain=xxx
DEBUG: [Set-StandardDatabaseMail] $mailServer=xxx
DEBUG: [Set-StandardDatabaseMail] $useDefaultCredentials=True
DEBUG: [Set-StandardDatabaseMail] $computerName=xxx
DEBUG: [Set-StandardDatabaseMail] $instanceName=xxx
DEBUG: [Set-StandardDatabaseMail] $instanceShort=xxx
DEBUG: [Set-StandardDatabaseMail] $accountSplat.EmailAddress=xxx
DEBUG: [Set-StandardDatabaseMail] $accountSplat.ReplyToAddress=xxx
VERBOSE: [Set-StandardDatabaseMail] xxx-Conference-EmailAddress : xxx -> yyy
VERBOSE: [Set-StandardDatabaseMail] xxx-Conference-ReplyToAddress : xxx -> yyy
What if: Performing the operation "Saving changes to db mail account xxx" on target "xxx".
DEBUG: [Set-StandardDatabaseMail] $global:DebugPreference=Continue
DEBUG: [Set-StandardDatabaseMail] $global:VerbosePreference=Continue
DEBUG: [Set-StandardDatabaseMail] END

Note: The function is incomplete. My desire is to run the function and magically check or set the complete database mail configuration on 50-100 SQL instances to conform to standards.

In my opinion, if it's very hard to find a good template or pattern for solving a problem, the tool likely has an issue. This is the case for T-SQL and stored procedures. Perhaps PowerShell is not as bad, but I'm not so sure in this case.

Also, common problems such as displaying values for debug could have a common template. I'm not fond of trying to create a format for messages. Wish there was an expertly defined format that provided natively.

UPDATE:

The use of $PSCmdlet.SessionState.PSVariable.GetValue will get the global value. It might be okay to force the use of the global values to make sure they are used. Definitely a design choice that adds a lot of code. Is it just a precaution from getting the incorrect preferences? It won't protect from temporarily setting global preferences and failing to set them back.

@drstonephd
Copy link

Please reopen this. Looks like the bot closed it. (Sweeping it under the carpet?)

@drstonephd
Copy link

drstonephd commented Dec 27, 2023

After that lengthy example, I have to wonder if all I needed to do was like below. In this case Set-StandardDatabaseMail would not have the "hack" code to set the private and global preference values. I don't know if it would handle Debug or Verbose switches as desired. I wish I knew the right way to handle this. If there is no fix, then perhaps a detailed document that explains how we should handle this?

$private:DebugPreference = 'Continue'
$private:VerbosePreference = 'Continue'
$global:DebugPreference = 'SilentlyContinue'
$global:VerbosePreference = 'SilentlyContinue'

Set-StandardDatabaseMail -SqlInstance XXX -WhatIf -InformationAction Continue

If I was using several modules and want to control them individually, this could become very cumbersome. Should preference variables be private by default scoped by fn and module?

UPDATE:

It does not work. Sort of expected as private makes it unavailable in the function. Global, Script, and Local scopes appear to be the same for me at this location. Any scope advice?

@drstonephd
Copy link

drstonephd commented Dec 28, 2023

There might be an easy workaround if a related VSCode "bug/feature" is fixed/added.

PowerShell/vscode-powershell#4327

VSCode dot sources scripts when run in the development environment using F5, so there is no separate script scope. However, when running from the command line, it is possible to not dot source. PowerShell ISE also dot sources scripts, so VSCode is consistent - for good or bad. There does not seem to be a direct way to test not dot-sourcing via VSCode and F5. (Is VSCode is forcing dot sourcing as a best practice by not allowing otherwise?)

If dot sourcing can be avoided, the global scope can be reserved for modules while script scope can be used for user script. Then the module preference "firewall" can be useful - even if still wonky. (Introducing a bad practice to compensate for issues controlling module preferences?)

Another consideration - must scripts work by design for both dot sourced or not dot sourced use? Perhaps there should be a script level "requires" to force the correct loading of the script? Or perhaps some variables can be forced into a script scope even if the script is dot sourced?

Anyway, here is a test script. It demonstrates that running code in VSCode/ISE produces different results from the same code run without dot sourcing.

$hostDescription = (Get-Host).ForEach({'{0} - {1}' -f $_.Name, $_.Version})

Write-Host "Host : $hostDescription"

$commandOrigin = $MyInvocation.CommandOrigin

Write-Host ( 'Command Origin : {0} ({1})' -f $commandOrigin, $(if ($commandOrigin -eq 'internal') {'dot sourced?'} else {'not dot sourced?'}) )

$cmd = $PSCommandPath # blank if F8
if ( -not $cmd -and $script:psEditor ) { $cmd = $script:psEditor.GetEditorContext().CurrentFile.Path } # F8 friendly
if ( -not $cmd -and $script:psISE ) { $cmd = $script:psISE.CurrentFile.FullPath } # F8 friendly if stuck with PS ISE because of a .Net Core clash

Write-Host "Command : $cmd"

$scriptName = $MyInvocation.MyCommand.Name # name of the running command or script

Write-Host "Script Name : $scriptName"

$DebugPreference = 'Continue'
$global:DebugPreference = 'SilentlyContinue' # last update wins if global is local

Write-Host ('[{0}] $global:DebugPreference={1}' -f $scriptName, $global:DebugPreference)
Write-Host ('[{0}] $script:DebugPreference={1}' -f $scriptName, $script:DebugPreference)
Write-Host ('[{0}] $local:DebugPreference={1}' -f $scriptName, $local:DebugPreference)
Write-Host ('[{0}] $DebugPreference={1}' -f $scriptName, $DebugPreference)

function Test-ScriptScope {
    $fnName = $MyInvocation.MyCommand.Name # name of the running command or script
    Write-Host ('[{0}] $global:DebugPreference={1}' -f $fnName, $global:DebugPreference)
    Write-Host ('[{0}] $script:DebugPreference={1}' -f $fnName, $script:DebugPreference)
    Write-Host ('[{0}] $local:DebugPreference={1}' -f $fnName, $local:DebugPreference)
    Write-Host ('[{0}] $DebugPreference={1}' -f $fnName, $DebugPreference)
}

Test-ScriptScope

The first two results show that both VSCode and Windows PowerShell ISE dot source by default when run. Note: the script file is open and run via F5.

Host : Visual Studio Code Host - 2023.8.0
Command Origin : Internal (dot sourced?)
Command : C:\Users\xxx\Documents\GitHub\sql-server-maintenance\Test-ScriptScope.ps1
Script Name : Test-ScriptScope.ps1
[Test-ScriptScope.ps1] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $script:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $local:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $DebugPreference=SilentlyContinue
[Test-ScriptScope] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope] $script:DebugPreference=SilentlyContinue
[Test-ScriptScope] $local:DebugPreference=
[Test-ScriptScope] $DebugPreference=SilentlyContinue
Host : Windows PowerShell ISE Host - 5.1.19041.3803
Command Origin : Internal (dot sourced?)
Command : C:\Users\xxx\Documents\GitHub\sql-server-maintenance\Test-ScriptScope.ps1
Script Name : Test-ScriptScope.ps1
[Test-ScriptScope.ps1] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $script:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $local:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $DebugPreference=SilentlyContinue
[Test-ScriptScope] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope] $script:DebugPreference=SilentlyContinue
[Test-ScriptScope] $local:DebugPreference=
[Test-ScriptScope] $DebugPreference=SilentlyContinue

It the script file is run without dot sourcing via command line, the results are different.

Host : Visual Studio Code Host - 2023.8.0
Command Origin : Runspace (not dot sourced?)
Command : C:\Users\xxx\Documents\GitHub\sql-server-maintenance\Test-ScriptScope.ps1
Script Name : Test-ScriptScope.ps1
[Test-ScriptScope.ps1] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $script:DebugPreference=Continue
[Test-ScriptScope.ps1] $local:DebugPreference=Continue
[Test-ScriptScope.ps1] $DebugPreference=Continue
[Test-ScriptScope] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope] $script:DebugPreference=Continue
[Test-ScriptScope] $local:DebugPreference=
[Test-ScriptScope] $DebugPreference=Continue
Host : Windows PowerShell ISE Host - 5.1.19041.3803
Command Origin : Runspace (not dot sourced)
Command : C:\Users\xxx\Documents\GitHub\sql-server-maintenance\Test-ScriptScope.ps1
Script Name : Test-ScriptScope.ps1
[Test-ScriptScope.ps1] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $script:DebugPreference=Continue
[Test-ScriptScope.ps1] $local:DebugPreference=Continue
[Test-ScriptScope.ps1] $DebugPreference=Continue
[Test-ScriptScope] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope] $script:DebugPreference=Continue
[Test-ScriptScope] $local:DebugPreference=
[Test-ScriptScope] $DebugPreference=Continue
Host : ConsoleHost - 5.1.19041.3803
Command Origin : Runspace (not dot sourced?)
Command : C:\Users\xxx\Documents\GitHub\sql-server-maintenance\Test-ScriptScope.ps1
Script Name : Test-ScriptScope.ps1
[Test-ScriptScope.ps1] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $script:DebugPreference=Continue
[Test-ScriptScope.ps1] $local:DebugPreference=Continue
[Test-ScriptScope.ps1] $DebugPreference=Continue
[Test-ScriptScope] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope] $script:DebugPreference=Continue
[Test-ScriptScope] $local:DebugPreference=
[Test-ScriptScope] $DebugPreference=Continue
Host : ConsoleHost - 7.4.0
Command Origin : Runspace (not dot sourced?)
Command : C:\Users\xxx\Documents\GitHub\sql-server-maintenance\Test-ScriptScope.ps1
Script Name : Test-ScriptScope.ps1
[Test-ScriptScope.ps1] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $script:DebugPreference=Continue
[Test-ScriptScope.ps1] $local:DebugPreference=Continue
[Test-ScriptScope.ps1] $DebugPreference=Continue
[Test-ScriptScope] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope] $script:DebugPreference=Continue
[Test-ScriptScope] $local:DebugPreference=
[Test-ScriptScope] $DebugPreference=Continue

So, it appears I need to not dot source my first file and then dot source the rest from that file. I suppose this is a VSCode nightmare to support natively. Perhaps two four options in VSCode - always dot source (current), never dot source (user must do it), dot source into a specific file (user sets once), or dot source into a single imaginary file (VSCode uses a hidden file script scope and dots to it). Head can start to hurt thinking about the complexity.

@drstonephd
Copy link

Here's a trick to create a new scope if the file was dot sourced. It's not the script scope, but might be okay for a simple call.

& {
$DebugPreference = 'Break'
Test-ScriptScope
}
Host : Visual Studio Code Host - 2023.8.0
Command Origin : Internal (dot sourced?)
Command : C:\Users\rstone\Documents\GitHub\sql-server-maintenance\Test-ScriptScope.ps1
Script Name : Test-ScriptScope.ps1
[Test-ScriptScope.ps1] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $script:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $local:DebugPreference=SilentlyContinue
[Test-ScriptScope.ps1] $DebugPreference=SilentlyContinue
Testing Test-ScriptScop
[Test-ScriptScope] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope] $script:DebugPreference=SilentlyContinue
[Test-ScriptScope] $local:DebugPreference=
[Test-ScriptScope] $DebugPreference=SilentlyContinue
Testing &{} with Test-ScriptScop
[Test-ScriptScope] $global:DebugPreference=SilentlyContinue
[Test-ScriptScope] $script:DebugPreference=SilentlyContinue
[Test-ScriptScope] $local:DebugPreference=
[Test-ScriptScope] $DebugPreference=Break

@sharzas
Copy link

sharzas commented Apr 9, 2024

So many years and no change - we are still struggling with this. mklement0 was pretty spot on from the beginning, and it is hard to grasp the perspectives that has been put onto this, attempting to justify inconsistent behavior with user vs developer perspectives. We're not developing to match everyones expectation, but to produce consistent outcomes and predictable behavior.

Imho - whether you are looking at this from a user perspective, or developer perspective, either one wants consistent behavior, independent of the source of the function thei're invoking. By having a requirement for detailed knowledge of the call stack from start to end, to be able to predict the outcome of preferences, does exactly complicate it for users and developers alike, and especially -WhatIf is a problematic preference variable/common parameter, since it can incur devastating consequences, which ultimately can't be predicted in the current state of affairs, if implemented in an advanced module function.

Keep in mind that in module development, we often call other functions in the module, to deal with specific tasks a greater function needs to accomplish, but at the same time the user/another developer may have the option to call those functions directly.

Users and developers alike would expect e.g. -WhatIf to perform as intended, regardless of how deep the function call is in the call stack. It could be catastrophic if we forgot to import preference variables from the callers scope, and even if we do remember it, the inheritance may have been severed by a module function somewhere else in the call stack that didn't do it - which btw. is completely out of our control, and not fixable by an opt-in change. This leaves the WhatIf parameter risky, and thereby imo useless in a module advanced function, as you cannot guarantee its propagation from parent scopes.

Propagating preference variables to descendent scopes should always have been the way, but the proposed option of adding a CmdletBindingAttribute for opting in to this, is however a truly elegant solution, that would solve part of the problem, and it won't even introduce breaking changes. You may even add more attributes, for more granular control - so one may wonder what is wrong with at least giving us this?

@JustinGrote
Copy link
Contributor

@SteveL-MSFT please reopen as active issue.

@JosephColvin
Copy link

JosephColvin commented Apr 10, 2024

In my modules I have found that you can parse the command that called your module load so you can take action on that....
[System.Management.Automation.Language.Parser]::ParseInput($MyInvocation.Statement, [ref]$ParsedTokens, [ref]$ParsedErrors)
This will let you parse the import-module command and the parameters. Since you are using your own invocation statement you do not have to validate the module that is loading is yours.

This is a quick sample of how to do it, in case you are like me and need to see code to understand what these words mean. Drop the following code into a module and when you load it use Verbose switch on the import, it should let you know... you can play with it as you see fit. But I was thinking that it should be able to actually implement this in the import command somehow. I wonder if a proxy command would work... you could do it and throw the parameters that you want into a set order for the 'args' array.... or even modify the bound parameters I guess to do it... thats an idea I haven't thought about... I'll go have some fun with that idea this weekend. Anyways, the code:

Code

#region Snippet - Import-Module State for Debug, Verbose, and Force.
#region Variables used and initialized to determine switch states
[System.Management.Automation.Language.Token[]]$local:ParsedTokens = @()
[System.Management.Automation.Language.ParseError[]]$local:ParsedErrors = @()
[System.Management.Automation.Language.ScriptBlockAst]$local:LastStatementAst = [System.Management.Automation.Language.Parser]::ParseInput($MyInvocation.Statement, [ref]$ParsedTokens, [ref]$ParsedErrors) # current, statement should only contain the import-module command that loads us
[System.Management.Automation.Language.CommandAst]$local:LastImportCommand = @(
    $local:LastStatementAst.Find({
        $args[0] -is [System.Management.Automation.Language.CommandAst] -and
        ([System.Management.Automation.Language.CommandAst]$args[0]).GetCommandName() -eq 'Import-Module'
    }, $false))[0] # first instance of 'Import-Module'
[System.Collections.ObjectModel.ReadOnlyCollection[System.Management.Automation.Language.CommandParameterAst]]$local:LastImportParameters = @($local:LastImportCommand.CommandElements |
    Where-Object {
        $_ -is [System.Management.Automation.Language.CommandParameterAst]
    }) # all instances of parameters used with the import
[System.Management.Automation.Language.CommandParameterAst]$local:CalledVerboseAst = @($local:LastImportParameters |
    Where-Object {
        $_ -is [System.Management.Automation.Language.CommandParameterAst] -and
        $_.ParameterName -eq 'Verbose'
    })[0] # first instance of 'Verbose'
[System.Management.Automation.Language.CommandParameterAst]$local:CalledDebugAst = @($local:LastImportParameters |
    Where-Object {
        $_ -is [System.Management.Automation.Language.CommandParameterAst] -and
        $_.ParameterName -eq 'Debug'
    })[0] # first instance of 'Debug'
[System.Management.Automation.Language.CommandParameterAst]$local:CalledForcedAst = @($local:LastImportParameters |
    Where-Object {
        $_ -is [System.Management.Automation.Language.CommandParameterAst] -and
        $_.ParameterName -eq 'Force'
    })[0] # first instance of 'Force'
#endregion Variables used and initialized
#region Setup script variables
[bool]$Script:IsVerbose = $false
[bool]$Script:IsDebug = $false
[bool]$Script:IsForce = $false
#region Set IsVerbose
if (-not $null -eq ${local:CalledVerboseAst}?.Argument) { # true means 'Verbose:[Variable | Value]' was used
    try {
        $script:IsVerbose = $local:CalledVerboseAst.Argument.SafeGetValue($true)
    } catch [System.InvalidOperationException] { # the extent is unsafe
        try { # fallback to extract the assigned value text to determine the state
            $script:IsVerbose = $local:CalledVerboseAst.Argument.Extent.Text.Replace('$','').ToUpper() -eq 'TRUE'
        } catch {
            $script:IsVerbose = $false
        }
    }
} elseif (-not $null -eq $local:CalledVerboseAst) { # else see if the switch is present, which means true for the value
    $script:IsVerbose = $true
}
#endregion Set IsVerbose
#region Set IsDebug
if (-not $null -eq ${local:CalledDebugAst}?.Argument) { # true means 'Debug:[Variable | Value]' was used
    try {
        $script:IsDebug = $local:CalledDebugAst.Argument.SafeGetValue($true)
    } catch [System.InvalidOperationException] { # the extent is unsafe
        try { # fallback to extract the assigned value text to determine the state
            $script:IsDebug = $local:CalledDebugAst.Argument.Extent.Text.Replace('$','').ToUpper() -eq 'TRUE'
        } catch {
            $script:IsDebug = $false
        }
    }
} elseif (-not $null -eq $local:CalledDebugAst) { # else see if the switch is present, which means true for the value
    $script:IsDebug = $true
}
#endregion Set IsDebug
#region Set IsForce
if (-not $null -eq ${local:CalledForcedAst}?.Argument) { # true means 'Force:[Variable | Value]' was used
    try {
        $Script:IsForce = $local:CalledForcedAst.Argument.SafeGetValue($true)
    } catch [System.InvalidOperationException] { # the extent is unsafe
        try { # fallback to extract the assigned value text to determine the state
            $script:IsForce = $local:CalledForcedAst.Argument.Extent.Text.Replace('$','').ToUpper() -eq 'TRUE'
        } catch {
            $script:IsForce = $false
        }
    }
} elseif (-not $null -eq $local:CalledForcedAst) { # else see if the switch is present, which means true for the value
    $script:IsForce = $true
}
#endregion Set IsDebug
#endregion Setup script variables
#region Proxy Commands
#region Write-Debug Proxy

function private:Write-Debug {
    [CmdletBinding(HelpUri='https://go.microsoft.com/fwlink/?LinkID=2097132', RemotingCapability='None')]
    param(
        [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)]
        [Alias('Msg')]
        [AllowEmptyString()]
        [string]
        ${Message})

    begin
    {
        try {
            $outBuffer = $null
            if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer))
            {
                $PSBoundParameters['OutBuffer'] = 1
            }

            <# NOTE: What happens here
                 if '-Debug' or '-Debug:<boolean value>' was supplied when
                importing this module we supply it to the Write-Debug cmdlet
                as well as long as one isn't already supplied.
            #>
            if ($Script:IsDebug -and -not $PSBoundParameters.ContainsKey('Debug')) {
                $PSBoundParameters.Add('Debug', $Script:IsDebug)
            }

            $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Utility\Write-Debug', [System.Management.Automation.CommandTypes]::Cmdlet)
            $scriptCmd = {& $wrappedCmd @PSBoundParameters }

            $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
            $steppablePipeline.Begin($PSCmdlet)
        } catch {
            throw
        }
    }

    process
    {
        try {
            $steppablePipeline.Process($_)
        } catch {
            throw
        }
    }

    end
    {
        try {
            $steppablePipeline.End()
        } catch {
            throw
        }
    }

    clean
    {
        if ($null -ne $steppablePipeline) {
            $steppablePipeline.Clean()
        }
    }
    <#

    .ForwardHelpTargetName Microsoft.PowerShell.Utility\Write-Debug
    .ForwardHelpCategory Cmdlet

    #>
}
#endregion Write-Debug Proxy
#region Write-Verbose Proxy
function Write-Verbose {
    [CmdletBinding(HelpUri='https://go.microsoft.com/fwlink/?LinkID=2097043', RemotingCapability='None')]
    param(
        [Parameter(Mandatory=$true, Position=0, ValueFromPipeline=$true)]
        [Alias('Msg')]
        [AllowEmptyString()]
        [string]
        ${Message})

    begin
    {
        try {
            $outBuffer = $null
            if ($PSBoundParameters.TryGetValue('OutBuffer', [ref]$outBuffer))
            {
                $PSBoundParameters['OutBuffer'] = 1
            }

            <# NOTE: What happens here
                 if '-Verbose' or '-Verbose:<boolean value>' was supplied when
                importing this module we supply it to the Write-Verbose cmdlet
                as well as long as one isn't already supplied.
            #>
            if ($Script:IsVerbose -and -not $PSBoundParameters.ContainsKey('Verbose')) {
                $PSBoundParameters.Add('Verbose', $Script:IsVerbose)
            }

            $wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand('Microsoft.PowerShell.Utility\Write-Verbose', [System.Management.Automation.CommandTypes]::Cmdlet)
            $scriptCmd = {& $wrappedCmd @PSBoundParameters }

            $steppablePipeline = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
            $steppablePipeline.Begin($PSCmdlet)
        } catch {
            throw
        }
    }

    process
    {
        try {
            $steppablePipeline.Process($_)
        } catch {
            throw
        }
    }

    end
    {
        try {
            $steppablePipeline.End()
        } catch {
            throw
        }
    }

    clean
    {
        if ($null -ne $steppablePipeline) {
            $steppablePipeline.Clean()
        }
    }
    <#

    .ForwardHelpTargetName Microsoft.PowerShell.Utility\Write-Verbose
    .ForwardHelpCategory Cmdlet

    #>
}
#endregion Write-Verbose Proxy
#endregion Proxy Commands
#region clean up
Remove-Variable -ErrorAction 'Ignore' -Scope 'local' -Name @(
    'ParsedTokens',
    'ParsedErrors',
    'LastStatementAst',
    'LastImportCommand',
    'LastImportParameters',
    'CalledVerboseAst',
    'CalledDebugAst',
    'CalledForceAst'
)
#endregion clean up
#region Debug
Write-Verbose "Verbose switch detected"
Write-Debug "Debug was $(($Script:IsDebug ? "used with a resolved value of $Script:IsDebug" : "not used"))"
Write-Debug "Verbose was $(($Script:IsVerbose ? "used with a resolved value of $Script:IsVerbose" : "not used"))"
#endregion Debug
#end#region Snippet - Import-Module State for Debug, Verbose, and Force.

Edited comment

I just wish I could make it cleaner and more compact that it would not be so distracting at the top of a module. I also wished that I could get this to format better in the view, but it doesn't for me.

Update on findings

You can actually compute this and then make proxy commands for Write-Verbose and Write-Debug to inject the 'Verbose' or 'Debug' respectively into the PSBoundParameters collection.
I haven't figured out a way to make it nicer looking and more automatic than the above code. Oh, and you don't want to export the functions write-debug or write-verbose because of the fact they take into account the flags.

@JosephColvin
Copy link

So I am thinking that I might be able to determine if the write-debug or write-verbose is being called from either the module or from a script/cmdlet/function... I might, if that is the case I might be able to build some proxy commands that will do the magic.

@wpcoc
Copy link

wpcoc commented May 10, 2024

This was closed as 'completed' in Nov 2023, but seems to still be an issue. It's frustrating having to work around this issue with:

-Verbose:($PSBoundParameters['Verbose'] -eq $true)

everywhere.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Issue-Discussion the issue may not have a clear classification yet. The issue may generate an RFC or may be reclassif Issue-Enhancement the issue is more of a feature request than a bug Resolution-No Activity Issue has had no activity for 6 months or more WG-Language parser, language semantics
Projects
None yet
Development

No branches or pull requests