5

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)

2
  • 2
    $InputObject | % $Scriptblock Commented Sep 26, 2017 at 14:38
  • Hmm...interesting. That does seem to work. Although I think it invokes in whatever context the scriptblock was defined, which is not always the caller's context. But now I'm wondering whether that's what SomeScriptblockInvoker should actually do. Commented Sep 26, 2017 at 14:42

3 Answers 3

4

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 &:

  • 'input_object' | % {$_} - The value of $_ is 'input_object' because the scriptblock is bound to %'s -Process parameter. That scriptblock is executed once for each object in the pipeline.
  • 'input_object' | . {process{$_}} and 'input_object' | & {process{$_}} - The value of $_ is 'input_object' because the $_ in the scriptblock is inside a process{} block which executes once for each object in the pipeline.

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 % command has other parameters corresponding to Begin{} and End{} blocks.
  • For variable assignments made by the scriptblock to have side-effects on the SessionState associated with the scriptblock, use an invocation method that does not add a scope to the stack. Otherwise, the variable assignment will only affect the newly-created scope and disappear once the scriptblock completes execution.

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.

Sign up to request clarification or add additional context in comments.

3 Comments

Both solutions offered are quite inefficient. The second alternative being the best one tho should be using a steppable pipeline $pipe = { ForEach-Object $Scriptblock }.GetSteppablePipeline()
@SantiagoSquarzon That seems like a better approach to me. I'm distracted, though, by why the original question was to invoke the script block "the caller's context" rather than "in the script block's" context. Those are rather different goals. The question asks for the former, but this answer is the latter. So this answer is also not on point in that respect.
@SantiagoSquarzon I've update my answer with the steppable pipeline (as you show in your answer) plus the machinery required to bind the script block to the chosen session state. There has been some skepticism about whether technique I used for binding to the caller's session state should be expected to be available in future. Do you know of a way to achieve that binding using a more obviously supported technique?
2

Since nobody suggested it, the easiest way to do it is to create a steppable pipeline of ForEach-Object. The 2nd option on the accepted answer does the work as it wont require the user to manually add a process { } block, however it is quite inefficient as it is invoking a cmdlet which then invokes the scriptblock (it uses InvokeUsingCmdlet, see RiverHeart helpful answer for details) for each input object.

Using a steppable pipeline the function could be:

New-Module TestModule {
    function Test-Invoke {
        [CmdletBinding(PositionalBinding = $false)]
        param(
            [Parameter(ValueFromPipeline)]
            [psobject] $InputObject,

            [Parameter(Mandatory, Position = 0)]
            [scriptblock] $Scriptblock
        )

        begin {
            $pipe = { ForEach-Object $Scriptblock }.GetSteppablePipeline()
            $pipe.Begin($PSCmdlet)
        }
        process {
            $pipe.Process($InputObject)
        }
        end {
            $pipe.End()
        }
    }
} | Import-Module

$i = 0; 0..10 | Test-Invoke { $_; $i++ }; $i
# Should output 0 to 11

There is another alternative that also uses a steppable pipeline an doesn't involve reflection however it is more complex than this one. The way to do it would be, first by converting the argument $ScriptBlock to a process block via AST Manipulation, example in UseObjectCommand.cs#L65-L90 then passing that converted scriptblock as argument of a new scriptblock you're stepping on, example in: UseObjectCommand.cs#L45-L56.

Comments

2

Update 2024-12-30 #2

Working version courtesy of @SantiagoSquarzon

New-Module TestModule {
    function Test-InvokeUsingCmdlet {
        [CmdletBinding()]
        param(
            [Parameter(ValueFromPipeline)]
            [psobject] $InputObject,

            [Parameter(Mandatory, Position = 0)]
            [scriptblock] $Scriptblock
        )

        begin {
            $method = [System.Management.Automation.ScriptBlock].GetMethod(
                'InvokeUsingCmdlet',
                [System.Reflection.BindingFlags] 'NonPublic, Instance')

            $autoNull = [System.Management.Automation.Internal.AutomationNull]::Value
        }
        process {


            # If the cmdletToUse is not null, then the result
            # of the evaluation will be streamed out the
            # output pipe of the cmdlet. In other words, you can't
            # do `$Result = $method.Invoke($Scriptblock, $params)`
            $params =
                $PSCmdlet,    # contextCmdlet: this
                $false,       # useLocalScope: false
                1,            # errorHandlingBehavior: ErrorHandlingBehavior.WriteToCurrentErrorPipe = 1
                $InputObject, # dollarUnder: InputObject
                $InputObject, # input: new object[] { InputObject }
                $autoNull,    # scriptThis: AutomationNull.Value
                $null         # args: Array.Empty<object>()

            $method.Invoke($Scriptblock, $params)
        }
    }
} | Import-Module

0..10 | Test-InvokeUsingCmdlet { $_ }

Update 2024-12-30

Was calling the wrong method signature for InvokeScript so that has been updated.

Unfortunately, I haven't found a way to set $_ at the same time so this can't really be called an answer although I think the information here is helpful nonetheless.

There are many methods that invoke scriptblocks but none of the publicly accessible ones I've found provide the necessary functions. While InvokeScript is enough to fix the scoping issue, it doesn't set $_ whereas you can set it using InvokeWithContext as a PSVariable or with InvokeWithPipe (see here) using a bool, however that method is hidden from Powershell.

Original (ish)

For context, I started poking around MeasureCommand which calls InvokeWithPipe so I looked to find where, if anywhere, it was exposed.

It exists under [System.Management.Automation] and I remembered that the EngineIntrinsics type exists because it's used in custom Powershell validation attributes so I looked there. I didn't find what I wanted exactly but came across this post which didn't have the answer but reminded me $ExecutionContext exists. Looking at it, I found $ExecutionContext.InvokeCommand.InvokeScript() and referenced MeasureCommand for how the params were used. Setting useLocalScope to false gets it to execute in the current context so that's key.

Note: The results of Invoke-ScriptBlock don't seem to display until the script finishes so Write-Host always comes before the returned object which feels off but if you change Write-Host to Write-Output they at least return in the correct order.

Hope it helps.

New-Module ModMadeInHeaven {
    function Invoke-ScriptBlock {
        [CmdletBinding()]
        param(
            [Parameter(Mandatory)]
            [Scriptblock] $Scriptblock,

            [Parameter(ValueFromPipeline)]
            [Object] $InputObject
        )

        begin {
            $PipeResult = [System.Management.Automation.Runspaces.PipelineResultTypes]::Output
        }
        
        process {
            $ExecutionContext.InvokeCommand.InvokeScript(
                <# bool useLocalScope #> $False,
                <# string script #> $ScriptBlock,
                <# IList input #> $null,                 # Sets the $input variable
                <# Params System.Object[] args #> $null  # Sets the $args variable
            )
        }
    }
} | Import-Module

$Original = 'original'

'InputObject' | Invoke-ScriptBlock {
    $Original = 'modified'
    [PSCustomObject] @{
        Local = $Original
        DollarBar = $_
    }
} | Format-List

Write-Host "Original: $Original"

Output

Local     : modified
DollarBar : 

Original: modified

7 Comments

You can use the input argument to represent $_, that will work just fine: 0..10 | & {param([scriptblock] $s, [Parameter(ValueFromPipeline)] $i) process { $ExecutionContext.InvokeCommand.InvokeScript($false, $s, @($i), $null) } } { "hello $_" }. For the internal function, the one of interest in this case is InvokeUsingCmdlet more than InvokeWithPipe. There are other ways you can use, for example with a steppable pipeline: github.com/santisq/PSUsing/blob/main/src/PSUsing/Commands/…
Or using a PSInstance with RunspaceMode.CurrentRunspace stackoverflow.com/a/79256609/15339544 with useLocalScope set to false to invoke in the callers scope, and from there you can use .AddCommand("Set-Variable").AddParameters(@{ Name = '_'; Value = $InputObject } to represent $_
@SantiagoSquarzon Wow, you are good, but I've seen you enough around the Powershell community to know that already ;). I definitely wouldn't mind you posting that gist here as an answer since you did all that work.
feel free to use it ;) i won't post an answer. unfortunately pwsh team didnt make a public API for this specific problem
|

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.