functionpowershellscopepipelinescriptblock

How can I invoke a scriptblock in the caller's context?


Consider the following call site:

$modifiedLocal = 'original local value'
'input object' | SomeScriptblockInvoker {
    $modifiedLocal = 'modified local value'
    [pscustomobject] @{
        Local = $local
        DollarBar = $_
    }
}
$modifiedLocal

I would like to implement SomeScriptblockInvoker such that

  1. it is defined in a module, and
  2. the scriptblock is invoked in the caller's context.

The output of the function at the call site would be the following:

Local DollarBar   
----- ---------   
local input object
modified local value

PowerShell seems to be capable of doing this. For example replacing SomeScriptblockInvoker with ForEach-Object yields exactly the desired output.

I have come close using the following definition:

New-Module m {
    function SomeScriptblockInvoker {
        param
        (
            [Parameter(Position = 1)]
            [scriptblock]
            $Scriptblock,

            [Parameter(ValueFromPipeline)]
            $InputObject
        )
        process
        {
            $InputObject | . $Scriptblock
        }
    }
} |
    Import-Module

The output of the call site using that definition is the following:

Local DollarBar
----- ---------
local          
modified local value

Note that DollarBar is empty when it should be input object.

(gist of Pester tests to check for correct behavior)


Solution

  • Much of the discussion in my original answer below remains correct, but, that answer has shortcomings as follows:

    1. Using steppable pipeline is probably more efficient as @SantiagoSquarzon pointed out. The approach employed by SomeScriptblockInvoker in option 1 below results in binding each input object twice. I think at least one of those parameter bindings is averted using the steppable pipeline.
    2. The request from the original question to invoke the script block in the caller's session state is not achieved.
    3. It asserts that invoking in the caller's session state is not possible. There is a way to achieve that, but it's not clear whether the ability to do so is intentional.

    The implemenation below uses both the steppable pipeline and allows for selection of the session state in which to invoke the script block. Santiago's answer already demonstrates an implementation of a function to replace SomeScriptBlockInvoke using the steppable pipeline. The following script block adds the machinery required to select the session state:

    New-Module {
    $label = 'invoker session state'
    function SomeScriptblockInvoker   {
        param(
            [Parameter(Mandatory)]
            [scriptblock]
            $ScriptBlock,
    
            [Parameter()]
            [ValidateSet('ScriptBlock','this')]
            $InSessionState,
    
            [Parameter(ValueFromPipeline)]
            $InputObject
        )
        begin {
            $steppable =
                switch ($InSessionState) {
                    'ScriptBlock' {
                        { ForEach-Object $ScriptBlock }.GetSteppablePipeline()
                    }
                    'this'        {
                        $m = [psmoduleinfo]::new(<# linkToGlobal: #> $false)
                        $m.SessionState = $PSCmdlet.SessionState
                        { . $m {
                                param($sb) $input |
                                ForEach-Object $sb.Ast.GetScriptBlock()
                            } $ScriptBlock
                        }.GetSteppablePipeline()
    
                    }
                }
            $steppable.Begin($true)
        }
        process { $steppable.Process($InputObject) }
        end     { $steppable.End() }
    }} |
        Import-Module
    
    # module to demonstrate SessionState selection
    New-Module {
        $label = 'script block origin session state'
        function Get-SomeScriptBlock { { "$label $_" } } # script block bound to this module's session state
    } |
        Import-Module
    
    $sb = Get-SomeScriptBlock
    
    $label = 'invocation call site session state'
    1..2 | SomeScriptblockInvoker $sb -InSessionState ScriptBlock
    3..4 | SomeScriptblockInvoker $sb -InSessionState this
    

    Invoking that outputs

    script block origin session state 1
    script block origin session state 2
    invocation call site session state 3
    invocation call site session state 4
    

    which demonstrates the same script block was invoked with each of the two session states.

    Note that the availability of the technique used to achieve -InSessionState this might be unintentional. That technique was first suggested to me by Patrick Meinecke where he wondered whether this ability was intentional:

    ...The SessionState property is writable (mistake, maybe?)

    Jason Shirk concurred that this ability seems unintentional:

    I agree...that a writable SessionState property in ModuleInfo seems unintentional. Changes to apis are generally conservative, so maybe you'd be safe using it, but I'd continue looking for alternatives so you have options to consider, or even fall back on if the api was removed.


    Original Answer, 2018-02-09

    In general, you can't. The caller of a scriptblock does not have control over the SessionState associated with that scriptblock and that SessionState determines (in part) the context in which a scriptblock is executed (see the Scope section for details). Depending on where the scriptblock is defined, its SessionState might match the caller's context, but it might not.

    Scope

    With respect to the context in which the scriptblock is executed, there are two related considerations:

    1. The SessionState associated with the scriptblock.
    2. Whether or not the calling method adds a scope to the SessionState's scope stack.

    Here is a good explanation of how this works.

    The $_ Automatic Variable

    $_ contains the current object in the pipeline. The scriptblock provided to % is interpreted differently from the scriptblock provided . and &:

    Note that in the OP $_ was empty when the scriptblock was invoked using .. This is because the scriptblock contained no process{} block. Each of the statements in the scriptblock were implicitly part of the scriptblock's end{} block. By the time an end{} block is run, there is no longer any object in the pipeline and $_ is null.

    . vs & vs %

    ., &, and % each invoke the scriptblock using the SessionState of the scriptblock with some differences according to the following table:

    +---+-----------------+-----------+-------------------+----------------------+
    |   |      Name       |    Kind   |  interprets {} as |  adds scope to stack |
    +---+-----------------+-----------+-------------------+----------------------+
    | % |  ForEach-Object |  Command  |  Process block    |  No                  |
    | . |  dot-source     |  Operator |  scriptblock      |  No                  |
    | & |  call           |  Operator |  scriptblock      |  Yes                 |
    +---+-----------------+-----------+-------------------+----------------------+
    

    The Most Viable Options

    The two most viable options for passing the tests in OP are as follows. Note that neither invokes the scriptblock strictly in the caller's context but rather in a context using the SessionState associated with the scriptblock.

    Option 1

    Change the call site so that the scriptblock includes process{}:

    $modifiedLocal = 'original local value'
    'input object' | SomeScriptblockInvoker {
        process {
            $modifiedLocal = 'modified local value'
            [pscustomobject] @{
                Local = $local
                DollarBar = $_
            }
        }
    }
    $modifiedLocal
    

    And invoke the scriptblock using SomeScriptblockInvoker in OP.

    Option 2

    Invoke the scriptblock using % as suggested by PetSerAl.