alx9r
alx9r

Reputation: 4251

Can a function be created in the global scope from a .ps1 script's script block?

Consider a PowerShell script named s.ps1 with content

1/0

and the hypothetical function

NAME
    New-GlobalFunction

SYNOPSIS
    Creates a new function in global scope from a script.

SYNTAX
    New-GlobalFunction [-ScriptPath] <string> [-FunctionName] <string>

I am hoping to be able to define global function Invoke-S using New-GlobalFunction such that Invoke-S produces error position information showing the correct file similar to the following:

PS C:\> New-GlobalFunction -ScriptPath .\s.p1 -FunctionName Invoke-S; Invoke-S
RuntimeException: C:\s.ps1:1
Line |
   1 |  1/0
     |  ~~~
     | Attempted to divide by zero.
----------

Attempted to divide by zero.
at <ScriptBlock>, C:\s.ps1: line 1

Note the mention of the original file c.ps1.

The architecture of PowerShell suggests this ought to be possible since scripts and functions are just script blocks:

In the PowerShell programming language, a script block is a collection of statements or expressions that can be used as a single unit. The collection of statements can be enclosed in braces ({}), defined as a function, or saved in a script file.

Indeed the script block defined by s.ps1 is accessible by Get-Command .\s.ps1 | % ScriptBlock. The error with the correct location information above was produced by invoking that script block. In fact, that script block can be turned into a local function with the command

New-Item -Path Function: -Name Invoke-S -Value (Get-Command .\s.ps1 | % ScriptBlock)

I have not, however, succeeded at creating such a global function.

Is there a way to define New-GlobalFunction such that an error produced by the script block defined in the file at -ScriptPath contains the correct file in its error position message?

Methods that don't work

The code below attempts the different methods I have tried for creating Invoke-S. It outputs the following:

method                    new_error                 invoke_error                           stacktrace
------                    ---------                 ------------                           ----------
iex function global                                 Attempted to divide by zero.           at global:Invoke-S, <No file>…
function global: $sb      Missing function body in… The term 'Invoke-S' is not recognized… at <ScriptBlock>, C:\Users\Us…
New-Item Function:                                  The term 'Invoke-S' is not recognized… at <ScriptBlock>, C:\Users\Us…
New-Item global:Function: Cannot find drive. A dri… The term 'Invoke-S' is not recognized… at <ScriptBlock>, C:\Users\Us…
Get-Variable function:    Cannot find a variable w… The term 'Invoke-S' is not recognized… at <ScriptBlock>, C:\Users\Us…

These methods either do not create the function such that it is available in the caller's scope (error messages 'Invoke-S' is not recognized, or the stack trace does not mention the correct file (error message at global:Invoke-S, <No file>).

function New-GlobalFunction {
param(
    [Parameter(Mandatory)][string]   $ScriptPath  ,
    [Parameter(Mandatory)][string]   $FunctionName,
    [Parameter(Mandatory)][ValidateSet(
        'iex function global'      ,
        'function global: $sb'     ,
        'New-Item Function:'       ,
        'New-Item global:Function:',
        'Get-Variable function:'    )]$Method
)
switch ($Method) {
    'iex function global' {
        Invoke-Expression `
            -Command "function global:$FunctionName {
                        $(Get-Content $ScriptPath)
                     }"
    }
    'function global: $sb' {
        $sb =
            Get-Command           `
                -Name $ScriptPath |
            % ScriptBlock
        # function global:Invoke-S $sb
        throw 'Missing function body in function declaration.'
    }
    'New-Item Function:' {
        New-Item                       `
            -ErrorAction Stop          `
            -Path        Function:     `
            -Name        $FunctionName `
            -Value (Get-Command $ScriptPath |
                    % ScriptBlock            )
    }
    'New-Item global:Function:' {
        New-Item                    `
            -ErrorAction Stop       `
            -Path  global:Function: `
            -Name  $FunctionName    `
            -Value (Get-Command $ScriptPath |
                    % ScriptBlock            )
    }
    'Get-Variable function:' {
        Get-Variable          `
            -Name 'function:' `
            -ErrorAction Stop
    }
}}

$(foreach ($method in 'iex function global'         ,
                      'function global: $sb',
                      'New-Item Function:'          ,
                      'New-Item global:Function:'   ,
                      'Get-Variable function:'       ) {
    [pscustomobject]@{
        method       = $method
        new_error    = $(try {$null =
                              New-GlobalFunction       `
                                -ScriptPath  .\s.ps1   `
                                -FunctionName Invoke-S `
                                -Method       $method   }
                        catch {$_})
        invoke_error = ($e =
                       try {Invoke-S -ErrorAction Stop}
                       catch {$_}     )
        stacktrace   = $e.ScriptStackTrace
        e            = $e
    }
    Remove-Item Function:Invoke-S -ErrorAction SilentlyContinue
}) |
    Format-Table `
        -Property @{e='method'      ;width=25},
                  @{e='new_error'   ;width=25},
                  @{e='invoke_error';width=38},
                  @{e='stacktrace'  ;width=30}

References

Upvotes: 1

Views: 91

Answers (2)

mklement0
mklement0

Reputation: 439228

The following, using namespace variable notation, seems to work:

$FunctionName = 'Invoke-S'
$ScriptPath = '.\s.ps1'
Invoke-Expression @"
`${Function:global:$FunctionName} = (Get-Command "$ScriptPath").ScriptBlock
"@

The cmdlet-based equivalent of the above is actually simpler in this case, because the function name can directly be expressed in terms of a variable:

$FunctionName = 'Invoke-S'
$ScriptPath = '.\s.ps1'
Set-Content Function:global:$FunctionName (Get-Command $ScriptPath).ScriptBlock

In the context of your function:

function New-GlobalFunction {
  param(
    [Parameter(Mandatory)] [string] $ScriptPath,
    [Parameter(Mandatory)] [string] $FunctionName
  )
  Set-Content Function:global:$FunctionName (Get-Command $ScriptPath).ScriptBlock
}

# Sample call
New-GlobalFunction -ScriptPath .\s.ps1 -FunctionName Invoke-S

Explanation:

Specifically, the variable expression ${Function:global:Invoke-S} (the expanded form interpreted by Invoke-Expression[1]) / the (positionally implied) -Path argument with value Function:global:Invoke-S passed to Set-Content breaks down as follows:

  • Function: references the built-in Function: drive exposed by the Function provider

  • global:Invoke-S refers to a function named Invoke-S in the global scope; that is, global: is a scope modifier.

    • Such scope modifiers aren't often seen in function definitions, but they do work; e.g., function global:Test-Me { 'hi!' } defines a Test-Me function in the global scope.
  • The function name includes -, which is a character that requires enclosure of the entire (namespace-notation) variable-name expression in {...} in order to be recognized as a single variable expression.

    • Note how this is not necessary in argument-parsing mode in the Set-Content solution; Function:global:Invoke-S works fine as a single argument.

By assigning to ${Function:global:Invoke-S} / using that name as a path with Set-Content, the global Invoke-S function is either created or updated; what must be assigned / passed to the (positionally implied) -Value parameter of Set-Content is the function body (similarly, getting the value of this variable / using Get-Content Function:global:Invoke-S would return the function's body as a script block, assuming the function exists).

  • As an alternative to assigning / passing a script block, you can also assign / pass the body as a string (source-code text); however, in the case at hand only assigning a script block obtained via Get-Command preserves the full origin information, namely including the path of the originating .ps1 file.

Variant solution for defining a function in the caller's scope:

  • The solution above hinges on being able to use a scope modifier in the function name, namely global:

  • However, there is no way to specify a relative scope by way of a scope modifier, as would be needed if you wanted to define a function in the caller's scope - whatever scope the caller lives in.

    • Relative scopes, e.g. 1 to refer to the parent scope, are only supported via the -Scope parameter of entity-specific cmdlets, such as Set-Variable and Set-Alias; there is no Set-Function cmdlet.

      • Conceivably, however, even the generic provider cmdlets, such as New-Item and Set-Content, could be extended to provide entity-specific dynamic parameters (an existing example of such parameters are the -File and -Directory switches that are specific to the FileSystem provider).
    • This omission is the subject of GitHub discussion #16881 (reader discretion advised, due to inappropriate language), and the only - suboptimal - workaround for now is to use .NET reflection in order to access non-public members (whose continued existence [in that form] isn't guaranteed), notably the internal .GetScopeByID() method, as you note, which itself can only be accessed via an object obtained via another non-public API.

    • The solution below uses a simplified version of the code in the linked discussion, and, unlike the former, also supports putting the function, New-FunctionInCallerScope, inside a module, although the assumption is that it is only ever called from outside that module.

function New-FunctionInCallerScope {
  param(
    [Parameter(Mandatory)] [string] $ScriptPath,
    [Parameter(Mandatory)] [string] $FunctionName
  )

  # Binding flags for finding non-public instance members via reflection.
  $bindingFlags = [System.Reflection.BindingFlags] 'NonPublic, Instance'
  # Get the caller's internal session state, via $PSCmdlet.SessionState
  $sessionStateInternal = [System.Management.Automation.SessionState].
                            GetMethod('get_Internal', $bindingFlags).
                            Invoke($PSCmdlet.SessionState, $null)
  # Determine the scope index relative to $PSCmdlet.SessionState[Internal],
  # which is the *caller's* session state:
  # * If this function is inside a module, it lives in a different "session state"
  #   (scope domain), so use the caller's *current* scope (0) 
  #   (the caller is assumed to be an *outside* caller).
  # * If this function is a non-module function, it runs in the same "session state"
  #   as the caller, but in a *child* scope (unless dot-sourced), so use the parent
  #   scope (1).
  $callerScopeIndex = if ($MyInvocation.MyCommand.Module) { 0 } else { 1 }
  # Retrieve the target scope.
  $scope = $sessionStateInternal.GetType().
             GetMethod('GetScopeByID', $bindingFlags, [type[]] [int]).
             Invoke($sessionStateInternal, $callerScopeIndex)
  # Get the scope's function table. 
  $funcTable = $scope.GetType().
                 GetMethod('get_FunctionTable', $bindingFlags).
                 Invoke($scope, $null)
  # Create / update the function with the given name and script block from the given file.
  # Note: A [System.Management.Automation.FunctionInfo] instance must be assigned, which is
  #       is obtained via an aux. transitory, scope-local function whose name doesn't matter.
  $funcTable[$FunctionName] = New-Item Function:UnusedName -Value (Get-Command $ScriptPath).ScriptBlock
}

# Sample calls:

# Define function Invoke-S in the caller's scope.
New-FunctionInCallerScope -ScriptPath .\s.ps1 -FunctionName Invoke-S

# The function is now visible in this scope (and its descendants), 
# but not in any parent scope.
Invoke-S

[1] Note that while use of Invoke-Expression (iex) is safe in this particular case, it should generally be avoided.

Upvotes: 2

Santiago Squarzon
Santiago Squarzon

Reputation: 60518

An alternative storing the CommandInfo in the value script block.

function New-GlobalFunction {
    param(
        [Parameter(Mandatory)]
        [string] $ScriptPath,

        [Parameter(Mandatory)]
        [string] $FunctionName,

        [Parameter()]
        [switch] $Force
    )

    $command = Get-Command $ScriptPath -ErrorAction Stop
    $null = $PSCmdlet.SessionState.InvokeProvider.Item.New(
        'function:',
        "global:$FunctionName",
        'Function',
        $command.ScriptBlock,
        $Force.IsPresent)
}

Upvotes: 1

Related Questions